march-cli 0.1.4 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -18,16 +18,17 @@ import { resolveRunnerSessionOptions } from "./session/session-options.mjs";
18
18
  import { createSessionBinding } from "./session/session-binding.mjs";
19
19
  import { maybeAutoNameSession } from "./session/session-auto-name.mjs";
20
20
  import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
21
- import { isModelStreamIdleTimeoutError, runRunnerTurn } from "./turn/turn-runner.mjs";
21
+ import { runRunnerTurn } from "./turn/turn-runner.mjs";
22
22
  import { appendFastVariants, createFastModelEntry, fromFastEntryModel, isFastProvider } from "./runner/fast-model.mjs";
23
23
  import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
24
+ import { registerCustomProviders } from "../provider/custom-provider.mjs";
24
25
 
25
26
  export { MARCH_BASE_TOOL_NAMES };
26
27
  export { installModelPayloadDumper } from "./model-payload-dumper.mjs";
27
28
  export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
28
29
  export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
29
30
 
30
- export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, centerMemoryPath = null, memoryStore = null, memoryTools = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, modelStreamIdleTimeoutMs = 7000, modelStreamIdleMaxRetries = 5 }) {
31
+ export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, centerMemoryPath = null, memoryStore = null, memoryTools = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null }) {
31
32
  if (!useRuntimeHost && extensionPaths.length > 0) {
32
33
  throw new Error("--extension requires the default pi runtime host path");
33
34
  }
@@ -38,6 +39,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
38
39
  const resolvedAuth = authConfig.authStorage;
39
40
  const modelRegistry = ModelRegistry.create(resolvedAuth);
40
41
  registerSuperGrokProvider(modelRegistry);
42
+ registerCustomProviders(modelRegistry, providers);
41
43
  const selectedModel = resolveInitialModel({ modelRegistry, provider, modelId });
42
44
  if (!selectedModel) throw new Error("No authenticated models available. Run: march provider --config");
43
45
  provider = selectedModel.provider;
@@ -63,6 +65,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
63
65
  runtimeHost = await createRunnerRuntimeHost({
64
66
  cwd, stateRoot, provider, modelId,
65
67
  authStorage: resolvedAuth, settingsManager, modelRegistry,
68
+ providers,
66
69
  sessionManager: resolvedSessionManager, sessionBinding, engine, ui,
67
70
  projectMarchDir,
68
71
  memoryTools, memoryStore, shellRuntime, lspService, mcpTools, webTools,
@@ -113,7 +116,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
113
116
  const turnStartedAt = Date.now();
114
117
  try {
115
118
  const result = await runRunnerTurn({
116
- prompt, userMessage, options: { userRecallHints, currentProject, modelStreamIdleTimeoutMs, modelStreamIdleMaxRetries },
119
+ prompt, userMessage, options: { userRecallHints, currentProject },
117
120
  sessionBinding, engine, ui, projectMarchDir, memoryStore,
118
121
  setModelCallKind: (kind) => { currentModelCallKind = kind; },
119
122
  onMidTurnRecallHints: (hints) => { pendingMidTurnRecallHints.push(...hints); },
@@ -129,9 +132,6 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
129
132
  });
130
133
  return result;
131
134
  } catch (err) {
132
- if (isModelStreamIdleTimeoutError(err)) {
133
- nextTurnContextMode = "continueExistingPiTranscript";
134
- }
135
135
  lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
136
136
  status: "error",
137
137
  sessionName: engine.sessionName,
@@ -3,6 +3,7 @@ import { createMarchRuntimeFactory } from "./runtime-factory.mjs";
3
3
  import { createRuntimeHost } from "./runtime-host.mjs";
4
4
  import { resolveRunnerSessionOptions } from "../session/session-options.mjs";
5
5
  import { registerSuperGrokProvider } from "../../supergrok/provider.mjs";
6
+ import { registerCustomProviders } from "../../provider/custom-provider.mjs";
6
7
 
7
8
  export async function createRunnerRuntimeHost({
8
9
  cwd,
@@ -12,6 +13,7 @@ export async function createRunnerRuntimeHost({
12
13
  authStorage,
13
14
  settingsManager,
14
15
  modelRegistry,
16
+ providers = {},
15
17
  sessionManager,
16
18
  sessionBinding,
17
19
  engine,
@@ -42,6 +44,7 @@ export async function createRunnerRuntimeHost({
42
44
  resolveSessionOptions: ({ cwd: sessionCwd, services }) => {
43
45
  const activeModelRegistry = services.modelRegistry ?? modelRegistry;
44
46
  registerSuperGrokProvider(activeModelRegistry);
47
+ registerCustomProviders(activeModelRegistry, providers);
45
48
  return resolveRunnerSessionOptions({
46
49
  cwd: sessionCwd,
47
50
  provider,
@@ -1,10 +1,6 @@
1
1
  import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
2
2
  import { closeAssistantReply, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
3
3
 
4
- export function isModelStreamIdleTimeoutError(err) {
5
- return err?.code === "MODEL_STREAM_IDLE_TIMEOUT";
6
- }
7
-
8
4
  export async function runRunnerTurn({
9
5
  prompt,
10
6
  userMessage,
@@ -23,21 +19,13 @@ export async function runRunnerTurn({
23
19
  const {
24
20
  userRecallHints = [],
25
21
  currentProject = "",
26
- modelStreamIdleTimeoutMs = 7000,
27
- modelStreamIdleMaxRetries = 5,
28
22
  } = options;
29
23
  const activeSession = sessionBinding.get();
30
24
  const turnState = createTurnEventState();
31
25
  const midTurnRecallHints = [];
32
- const modelIdleWatchdog = createModelStreamIdleWatchdog({
33
- session: activeSession,
34
- ui,
35
- timeoutMs: modelStreamIdleTimeoutMs,
36
- });
37
26
  ui.turnStart();
38
27
 
39
28
  const unsubscribe = activeSession.subscribe((event) => {
40
- modelIdleWatchdog.handleEvent(event);
41
29
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
42
30
  if (event.type === "tool_execution_start") {
43
31
  const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
@@ -56,32 +44,12 @@ export async function runRunnerTurn({
56
44
  });
57
45
  setModelCallKind("user");
58
46
  try {
59
- await promptWithModelIdleRetry({
60
- session: activeSession,
61
- prompt: contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
62
- promptOptions: attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
63
- resetBeforeAttempt: contextMode === "rebuild",
64
- maxRetries: modelStreamIdleMaxRetries,
65
- watchdog: modelIdleWatchdog,
66
- });
67
- } catch (err) {
68
- if (!isModelStreamIdleTimeoutError(err)) throw err;
69
- finalizeTurn({
70
- prompt,
71
- userMessage,
72
- userRecallHints,
73
- currentProject,
74
- memoryStore,
75
- engine,
76
- ui,
77
- turnState,
78
- midTurnRecallHints,
79
- syncCurrentPiSidecar,
80
- autoNameSession,
81
- });
82
- throw err;
47
+ if (contextMode === "rebuild") resetPiMessageHistory(activeSession);
48
+ await activeSession.prompt(
49
+ contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
50
+ attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
51
+ );
83
52
  } finally {
84
- modelIdleWatchdog.stop();
85
53
  setModelCallKind("model");
86
54
  }
87
55
 
@@ -105,38 +73,6 @@ export async function runRunnerTurn({
105
73
  }
106
74
  }
107
75
 
108
- async function promptWithModelIdleRetry({ session, prompt, promptOptions, resetBeforeAttempt, maxRetries, watchdog }) {
109
- let attempt = 0;
110
- while (true) {
111
- attempt += 1;
112
- if (resetBeforeAttempt) resetPiMessageHistory(session);
113
- const messageHistorySnapshot = clonePiMessageHistory(session);
114
- watchdog.startAttempt({ attempt, maxAttempts: maxRetries + 1 });
115
- let idleTimedOut = false;
116
- try {
117
- await session.prompt(prompt, promptOptions);
118
- idleTimedOut = watchdog.consumeTimedOut();
119
- if (!idleTimedOut) {
120
- if (attempt > 1) watchdog.reportRecovered({ attempt: attempt - 1 });
121
- return;
122
- }
123
- } catch (err) {
124
- idleTimedOut = watchdog.consumeTimedOut();
125
- if (!idleTimedOut) throw err;
126
- } finally {
127
- watchdog.stop();
128
- }
129
-
130
- if (attempt > maxRetries) {
131
- const err = createModelStreamIdleTimeoutError(watchdog.timeoutMs);
132
- watchdog.reportExhausted({ attempt, error: err });
133
- throw err;
134
- }
135
- restorePiMessageHistory(session, messageHistorySnapshot);
136
- watchdog.reportRetry({ attempt, maxAttempts: maxRetries + 1 });
137
- }
138
- }
139
-
140
76
  function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession }) {
141
77
  closeAssistantReply({ ui, state: turnState });
142
78
  const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
@@ -154,95 +90,6 @@ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, me
154
90
  syncCurrentPiSidecar();
155
91
  }
156
92
 
157
- function createModelStreamIdleTimeoutError(timeoutMs) {
158
- const err = new Error(`Model stream idle for ${timeoutMs}ms`);
159
- err.code = "MODEL_STREAM_IDLE_TIMEOUT";
160
- return err;
161
- }
162
-
163
- function createModelStreamIdleWatchdog({ session, ui, timeoutMs }) {
164
- let timer = null;
165
- let active = false;
166
- let inTool = false;
167
- let timedOut = false;
168
-
169
- return {
170
- timeoutMs,
171
- startAttempt() {
172
- timedOut = false;
173
- inTool = false;
174
- active = true;
175
- arm();
176
- },
177
- handleEvent(event) {
178
- if (!active || timedOut) return;
179
- if (event.type === "tool_execution_start") {
180
- inTool = true;
181
- clear();
182
- return;
183
- }
184
- if (event.type === "tool_execution_end") {
185
- inTool = false;
186
- arm();
187
- return;
188
- }
189
- if (event.type === "message_update") arm();
190
- },
191
- stop() {
192
- active = false;
193
- clear();
194
- },
195
- consumeTimedOut() {
196
- const value = timedOut;
197
- timedOut = false;
198
- return value;
199
- },
200
- reportRetry({ attempt, maxAttempts }) {
201
- ui.retryStart?.({
202
- attempt,
203
- maxAttempts,
204
- delayMs: 0,
205
- errorMessage: `Model stream idle for ${timeoutMs}ms; retrying request`,
206
- });
207
- },
208
- reportRecovered({ attempt }) {
209
- ui.retryEnd?.({ success: true, attempt });
210
- },
211
- reportExhausted({ attempt, error }) {
212
- ui.retryEnd?.({ success: false, attempt, finalError: error.message });
213
- },
214
- };
215
-
216
- function arm() {
217
- clear();
218
- if (!active || inTool || timeoutMs <= 0) return;
219
- timer = setTimeout(() => {
220
- timedOut = true;
221
- active = false;
222
- clear();
223
- session.abortRetry?.();
224
- session.abort();
225
- }, timeoutMs);
226
- }
227
-
228
- function clear() {
229
- if (!timer) return;
230
- clearTimeout(timer);
231
- timer = null;
232
- }
233
- }
234
-
235
- function clonePiMessageHistory(session) {
236
- const messages = session?.agent?.state?.messages;
237
- if (!Array.isArray(messages)) return null;
238
- return JSON.parse(JSON.stringify(messages));
239
- }
240
-
241
- function restorePiMessageHistory(session, messages) {
242
- if (!Array.isArray(session?.agent?.state?.messages) || !Array.isArray(messages)) return;
243
- session.agent.state.messages = JSON.parse(JSON.stringify(messages));
244
- }
245
-
246
93
  function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
247
94
  if (!memoryStore) return [];
248
95
  const text = assistantRecallDeltaText(turnState);
@@ -13,11 +13,12 @@ export function createMarchAuthStorage({
13
13
  } = {}) {
14
14
  const resolvedAuthStorage = authStorage ?? AuthStorage.create(getMarchAuthPath(homeDir));
15
15
 
16
- for (const profile of Object.values(providers ?? {})) {
16
+ for (const [id, profile] of Object.entries(providers ?? {})) {
17
17
  if (!profile || typeof profile !== "object") continue;
18
18
  const type = profile.type ?? profile.provider;
19
+ const providerKey = type === "openai-compatible" ? id : type;
19
20
  const profileKey = profile.auth?.method === "apiKey" ? profile.auth?.apiKey : null;
20
- if (type && profileKey) resolvedAuthStorage.setRuntimeApiKey(type, profileKey);
21
+ if (providerKey && profileKey) resolvedAuthStorage.setRuntimeApiKey(providerKey, profileKey);
21
22
  }
22
23
  const hasStoredAuth = Boolean(resolvedAuthStorage.list?.().length);
23
24
  const hasConfiguredProvider = Object.values(providers ?? {}).some((profile) => {
@@ -0,0 +1,112 @@
1
+ const CUSTOM_PROVIDER_TYPE = "openai-compatible";
2
+ const DEFAULT_API = "openai-completions";
3
+ const SUPPORTED_APIS = new Set(["openai-completions", "openai-responses"]);
4
+ const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
5
+
6
+ export function registerCustomProviders(modelRegistry, providers = {}) {
7
+ if (!modelRegistry?.registerProvider) return [];
8
+ const registered = [];
9
+
10
+ for (const [providerId, profile] of Object.entries(providers ?? {})) {
11
+ if (!isCustomProviderProfile(profile)) continue;
12
+ modelRegistry.registerProvider(providerId, toProviderConfig(providerId, profile));
13
+ registered.push(providerId);
14
+ }
15
+
16
+ return registered;
17
+ }
18
+
19
+ export function isCustomProviderProfile(profile) {
20
+ return Boolean(profile && typeof profile === "object" && profile.type === CUSTOM_PROVIDER_TYPE);
21
+ }
22
+
23
+ function toProviderConfig(providerId, profile) {
24
+ const api = normalizeApi(providerId, profile.api ?? DEFAULT_API);
25
+ const baseUrl = requireString(providerId, "baseUrl", profile.baseUrl);
26
+ const models = normalizeModels(providerId, profile.models, { api, baseUrl });
27
+ return omitUndefined({
28
+ name: typeof profile.name === "string" && profile.name.trim() ? profile.name : providerId,
29
+ baseUrl,
30
+ apiKey: profile.auth?.method === "apiKey" ? profile.auth.apiKey : undefined,
31
+ api,
32
+ headers: normalizeHeaders(providerId, profile.headers),
33
+ authHeader: typeof profile.authHeader === "boolean" ? profile.authHeader : undefined,
34
+ models,
35
+ });
36
+ }
37
+
38
+ function normalizeModels(providerId, models, { api, baseUrl }) {
39
+ if (!Array.isArray(models) || models.length === 0) {
40
+ throw new Error(`Custom provider "${providerId}" requires a non-empty models array`);
41
+ }
42
+
43
+ return models.map((model, index) => {
44
+ if (!model || typeof model !== "object" || Array.isArray(model)) {
45
+ throw new Error(`Custom provider "${providerId}" model #${index + 1} must be an object`);
46
+ }
47
+ const id = requireString(providerId, `models[${index}].id`, model.id);
48
+ return omitUndefined({
49
+ ...model,
50
+ id,
51
+ name: typeof model.name === "string" && model.name.trim() ? model.name : id,
52
+ api: normalizeApi(providerId, model.api ?? api),
53
+ baseUrl: typeof model.baseUrl === "string" && model.baseUrl.trim() ? model.baseUrl : baseUrl,
54
+ reasoning: typeof model.reasoning === "boolean" ? model.reasoning : false,
55
+ input: normalizeInput(model.input),
56
+ cost: normalizeCost(model.cost),
57
+ contextWindow: normalizePositiveNumber(model.contextWindow, 128000),
58
+ maxTokens: normalizePositiveNumber(model.maxTokens, 4096),
59
+ headers: normalizeHeaders(providerId, model.headers),
60
+ });
61
+ });
62
+ }
63
+
64
+ function normalizeApi(providerId, api) {
65
+ if (typeof api !== "string" || !SUPPORTED_APIS.has(api)) {
66
+ throw new Error(`Custom provider "${providerId}" api must be "openai-completions" or "openai-responses"`);
67
+ }
68
+ return api;
69
+ }
70
+
71
+ function requireString(providerId, field, value) {
72
+ if (typeof value !== "string" || !value.trim()) {
73
+ throw new Error(`Custom provider "${providerId}" requires ${field}`);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ function normalizeInput(input) {
79
+ if (!Array.isArray(input) || input.length === 0) return ["text"];
80
+ const normalized = input.filter((item) => item === "text" || item === "image");
81
+ return normalized.length > 0 ? normalized : ["text"];
82
+ }
83
+
84
+ function normalizeCost(cost) {
85
+ if (!cost || typeof cost !== "object" || Array.isArray(cost)) return DEFAULT_COST;
86
+ return {
87
+ input: normalizeNumber(cost.input, 0),
88
+ output: normalizeNumber(cost.output, 0),
89
+ cacheRead: normalizeNumber(cost.cacheRead, 0),
90
+ cacheWrite: normalizeNumber(cost.cacheWrite, 0),
91
+ };
92
+ }
93
+
94
+ function normalizeHeaders(providerId, headers) {
95
+ if (headers == null) return undefined;
96
+ if (typeof headers !== "object" || Array.isArray(headers)) {
97
+ throw new Error(`Custom provider "${providerId}" headers must be an object`);
98
+ }
99
+ return Object.fromEntries(Object.entries(headers).filter(([, value]) => typeof value === "string"));
100
+ }
101
+
102
+ function normalizePositiveNumber(value, fallback) {
103
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
104
+ }
105
+
106
+ function normalizeNumber(value, fallback) {
107
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
108
+ }
109
+
110
+ function omitUndefined(value) {
111
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
112
+ }