march-cli 0.1.3 → 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 +3 -1
- package/src/agent/runner.mjs +39 -8
- package/src/agent/runtime/runner-runtime-host.mjs +3 -0
- package/src/agent/turn/turn-runner.mjs +32 -13
- package/src/assets/march-icon.png +0 -0
- package/src/auth/storage.mjs +3 -2
- package/src/cli/repl-loop.mjs +7 -1
- package/src/cli/slash-commands.mjs +17 -1
- package/src/cli/tui/input/mouse-selection-controller.mjs +3 -1
- package/src/cli/tui/output/scroll-state.mjs +2 -2
- package/src/cli/tui/output/text-line-renderer.mjs +50 -0
- package/src/cli/tui/output-buffer.mjs +40 -53
- package/src/cli/tui/render/render-scheduler.mjs +26 -0
- package/src/cli/tui/tool-rendering.mjs +3 -0
- package/src/cli/ui.mjs +6 -7
- package/src/config/loader.mjs +1 -1
- package/src/context/center-memory.mjs +14 -0
- package/src/context/engine.mjs +6 -1
- package/src/context/system-core/base.md +5 -1
- package/src/main.mjs +4 -0
- package/src/notification/desktop-notifier.mjs +189 -44
- package/src/provider/custom-provider.mjs +112 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "march-cli",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"test:shell-tui-real": "node test/shell-tui-real.acceptance.mjs",
|
|
20
20
|
"test:tui-key-real": "node test/tui-key-real.acceptance.mjs",
|
|
21
21
|
"context": "cd .. && node march-cli/bin/march.mjs --dump-context",
|
|
22
|
+
"notify:experiment": "node scripts/notify-experiment.mjs",
|
|
22
23
|
"publish:env": "node scripts/npm-publish-from-env.mjs"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
30
|
"@xterm/headless": "^5.5.0",
|
|
30
31
|
"marked": "^18.0.3",
|
|
32
|
+
"node-notifier": "^10.0.1",
|
|
31
33
|
"node-pty": "^1.1.0",
|
|
32
34
|
"typebox": "^1.0.58",
|
|
33
35
|
"web-tree-sitter": "^0.26.8"
|
package/src/agent/runner.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { createMarchLifecycleAdapter } from "../extensions/lifecycle-adapter.mjs
|
|
|
9
9
|
import { syncPiSessionSidecar } from "../session/sidecar-sync.mjs";
|
|
10
10
|
import { LspService } from "../lsp/service.mjs";
|
|
11
11
|
import { formatRecallHints } from "../memory/markdown-store.mjs";
|
|
12
|
-
import { appendProviderUserMessage, installModelPayloadDumper, replaceProviderContextMessages } from "./model-payload-dumper.mjs";
|
|
12
|
+
import { appendProviderUserMessage, estimateProviderPayloadTokens, installModelPayloadDumper, replaceProviderContextMessages } from "./model-payload-dumper.mjs";
|
|
13
13
|
import { resolveInitialModel, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
|
|
14
14
|
import { runRunnerCleanup } from "./runner/runner-cleanup.mjs";
|
|
15
15
|
import { createRunnerRuntimeHost } from "./runtime/runner-runtime-host.mjs";
|
|
@@ -21,13 +21,14 @@ import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
|
|
|
21
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, 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
|
+
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;
|
|
@@ -47,7 +49,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
47
49
|
retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
|
|
48
50
|
});
|
|
49
51
|
const lspService = new LspService({ cwd });
|
|
50
|
-
const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
|
|
52
|
+
const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, centerMemoryPath, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
|
|
51
53
|
const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
|
|
52
54
|
const sessionBinding = createSessionBinding(null);
|
|
53
55
|
let currentModelCallKind = "model";
|
|
@@ -55,6 +57,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
55
57
|
let currentTurnContextMode = "rebuild";
|
|
56
58
|
let nextTurnContextMode = "rebuild";
|
|
57
59
|
let pendingMidTurnRecallHints = [];
|
|
60
|
+
let lastNotificationResult = null;
|
|
58
61
|
let runtimeHost = null;
|
|
59
62
|
let lifecycleAdapter = null;
|
|
60
63
|
let _currentFastEntry = null;
|
|
@@ -62,6 +65,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
62
65
|
runtimeHost = await createRunnerRuntimeHost({
|
|
63
66
|
cwd, stateRoot, provider, modelId,
|
|
64
67
|
authStorage: resolvedAuth, settingsManager, modelRegistry,
|
|
68
|
+
providers,
|
|
65
69
|
sessionManager: resolvedSessionManager, sessionBinding, engine, ui,
|
|
66
70
|
projectMarchDir,
|
|
67
71
|
memoryTools, memoryStore, shellRuntime, lspService, mcpTools, webTools,
|
|
@@ -109,6 +113,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
109
113
|
currentTurnContextMode = contextMode;
|
|
110
114
|
nextTurnContextMode = "rebuild";
|
|
111
115
|
pendingMidTurnRecallHints = [];
|
|
116
|
+
const turnStartedAt = Date.now();
|
|
112
117
|
try {
|
|
113
118
|
const result = await runRunnerTurn({
|
|
114
119
|
prompt, userMessage, options: { userRecallHints, currentProject },
|
|
@@ -119,17 +124,19 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
119
124
|
autoNameSession,
|
|
120
125
|
contextMode,
|
|
121
126
|
});
|
|
122
|
-
await notifyTurnEndBestEffort(turnNotifier, {
|
|
127
|
+
lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
|
|
123
128
|
status: "success",
|
|
124
129
|
sessionName: engine.sessionName,
|
|
125
130
|
draft: result?.draft ?? "",
|
|
131
|
+
durationMs: Date.now() - turnStartedAt,
|
|
126
132
|
});
|
|
127
133
|
return result;
|
|
128
134
|
} catch (err) {
|
|
129
|
-
await notifyTurnEndBestEffort(turnNotifier, {
|
|
135
|
+
lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
|
|
130
136
|
status: "error",
|
|
131
137
|
sessionName: engine.sessionName,
|
|
132
138
|
errorMessage: err?.message ?? String(err),
|
|
139
|
+
durationMs: Date.now() - turnStartedAt,
|
|
133
140
|
});
|
|
134
141
|
throw err;
|
|
135
142
|
} finally {
|
|
@@ -176,6 +183,20 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
176
183
|
return [...new Set([...configured, ...available])];
|
|
177
184
|
},
|
|
178
185
|
getSessionStats() { return getRunnerSessionStats(sessionBinding.get(), runtimeHost); },
|
|
186
|
+
getLastNotificationResult() { return lastNotificationResult; },
|
|
187
|
+
async notifyTest({ title = "March", message = "If you see this, March runtime notifications work." } = {}) {
|
|
188
|
+
lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
|
|
189
|
+
status: "success",
|
|
190
|
+
sessionName: engine.sessionName,
|
|
191
|
+
title,
|
|
192
|
+
message,
|
|
193
|
+
durationMs: 0,
|
|
194
|
+
});
|
|
195
|
+
return lastNotificationResult;
|
|
196
|
+
},
|
|
197
|
+
estimateContextTokens(userMessage = "") {
|
|
198
|
+
return estimateProviderPayloadTokens(providerContextToPayload(engine.buildProviderContext(userMessage)));
|
|
199
|
+
},
|
|
179
200
|
setSessionName(name) {
|
|
180
201
|
const activeSession = sessionBinding.get();
|
|
181
202
|
activeSession.setSessionName?.(name);
|
|
@@ -256,11 +277,21 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
256
277
|
}
|
|
257
278
|
}
|
|
258
279
|
|
|
280
|
+
function providerContextToPayload(providerContext) {
|
|
281
|
+
return {
|
|
282
|
+
messages: [
|
|
283
|
+
{ role: "system", content: providerContext.system },
|
|
284
|
+
...(providerContext.userMessages ?? []).map((message) => ({ role: "user", content: message.content })),
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
async function notifyTurnEndBestEffort(turnNotifier, event) {
|
|
260
|
-
if (!turnNotifier?.notifyTurnEnd) return;
|
|
290
|
+
if (!turnNotifier?.notifyTurnEnd) return { ok: false, reason: "not-configured", results: [] };
|
|
261
291
|
try {
|
|
262
|
-
await turnNotifier.notifyTurnEnd(event);
|
|
263
|
-
} catch {
|
|
292
|
+
return await turnNotifier.notifyTurnEnd(event);
|
|
293
|
+
} catch (err) {
|
|
264
294
|
// Notification must never change turn behavior.
|
|
295
|
+
return { ok: false, reason: err?.message ?? String(err), results: [] };
|
|
265
296
|
}
|
|
266
297
|
}
|
|
@@ -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,
|
|
@@ -16,7 +16,10 @@ export async function runRunnerTurn({
|
|
|
16
16
|
autoNameSession,
|
|
17
17
|
contextMode = "rebuild",
|
|
18
18
|
}) {
|
|
19
|
-
const {
|
|
19
|
+
const {
|
|
20
|
+
userRecallHints = [],
|
|
21
|
+
currentProject = "",
|
|
22
|
+
} = options;
|
|
20
23
|
const activeSession = sessionBinding.get();
|
|
21
24
|
const turnState = createTurnEventState();
|
|
22
25
|
const midTurnRecallHints = [];
|
|
@@ -50,20 +53,19 @@ export async function runRunnerTurn({
|
|
|
50
53
|
setModelCallKind("model");
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
|
|
57
|
-
|
|
58
|
-
engine.recordTurn({
|
|
59
|
-
userMessage: userMessage ?? prompt.slice(0, 300),
|
|
60
|
-
assistantMessage: turnState.draft,
|
|
56
|
+
finalizeTurn({
|
|
57
|
+
prompt,
|
|
58
|
+
userMessage,
|
|
61
59
|
userRecallHints,
|
|
62
|
-
|
|
60
|
+
currentProject,
|
|
61
|
+
memoryStore,
|
|
62
|
+
engine,
|
|
63
|
+
ui,
|
|
64
|
+
turnState,
|
|
65
|
+
midTurnRecallHints,
|
|
66
|
+
syncCurrentPiSidecar,
|
|
67
|
+
autoNameSession,
|
|
63
68
|
});
|
|
64
|
-
|
|
65
|
-
autoNameSession?.();
|
|
66
|
-
syncCurrentPiSidecar();
|
|
67
69
|
return { draft: turnState.draft };
|
|
68
70
|
} finally {
|
|
69
71
|
ui.turnEnd();
|
|
@@ -71,6 +73,23 @@ export async function runRunnerTurn({
|
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession }) {
|
|
77
|
+
closeAssistantReply({ ui, state: turnState });
|
|
78
|
+
const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
|
|
79
|
+
engine.setPendingAssistantRecallHints?.(assistantRecallHints);
|
|
80
|
+
const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
|
|
81
|
+
|
|
82
|
+
engine.recordTurn({
|
|
83
|
+
userMessage: userMessage ?? prompt.slice(0, 300),
|
|
84
|
+
assistantMessage: turnState.draft,
|
|
85
|
+
userRecallHints,
|
|
86
|
+
assistantRecallHints: recordedAssistantRecallHints,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
autoNameSession?.();
|
|
90
|
+
syncCurrentPiSidecar();
|
|
91
|
+
}
|
|
92
|
+
|
|
74
93
|
function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
|
|
75
94
|
if (!memoryStore) return [];
|
|
76
95
|
const text = assistantRecallDeltaText(turnState);
|
|
Binary file
|
package/src/auth/storage.mjs
CHANGED
|
@@ -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.
|
|
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 (
|
|
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) => {
|
package/src/cli/repl-loop.mjs
CHANGED
|
@@ -92,7 +92,7 @@ export async function runInteractiveRepl({
|
|
|
92
92
|
});
|
|
93
93
|
if (slashResult.exit) break;
|
|
94
94
|
if (slashResult.handled) {
|
|
95
|
-
refreshStatusBar();
|
|
95
|
+
refreshStatusBar(contextTokenRefreshOptions(slashResult, runner));
|
|
96
96
|
continue;
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -116,6 +116,12 @@ export async function runInteractiveRepl({
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export function contextTokenRefreshOptions(slashResult, runner) {
|
|
120
|
+
if (!slashResult?.refreshContextTokens) return undefined;
|
|
121
|
+
if (typeof runner.estimateContextTokens !== "function") return undefined;
|
|
122
|
+
return { contextTokens: runner.estimateContextTokens("") };
|
|
123
|
+
}
|
|
124
|
+
|
|
119
125
|
function handleInlineCommand(trimmed, { cwd, ui, lastInlineShellCommand }) {
|
|
120
126
|
const inlineShell = parseInlineShellInput(trimmed, lastInlineShellCommand);
|
|
121
127
|
if (inlineShell.type === "error") {
|
|
@@ -40,11 +40,13 @@ export async function handleSlashCommand(trimmed, {
|
|
|
40
40
|
ui.writeln("Error: pi runtime host is not enabled");
|
|
41
41
|
return { handled: true };
|
|
42
42
|
}
|
|
43
|
+
let refreshContextTokens = false;
|
|
43
44
|
try {
|
|
44
45
|
const result = await runner.startNewSession();
|
|
45
46
|
if (result?.cancelled) {
|
|
46
47
|
ui.writeln("New session cancelled");
|
|
47
48
|
} else {
|
|
49
|
+
refreshContextTokens = true;
|
|
48
50
|
ui.clearOutput?.();
|
|
49
51
|
const bannerLines = typeof renderStartupBanner === "function" ? renderStartupBanner() : [];
|
|
50
52
|
if (bannerLines.length > 0) {
|
|
@@ -56,7 +58,7 @@ export async function handleSlashCommand(trimmed, {
|
|
|
56
58
|
} catch (err) {
|
|
57
59
|
ui.writeln(`Error: ${err.message}`);
|
|
58
60
|
}
|
|
59
|
-
return { handled: true };
|
|
61
|
+
return { handled: true, refreshContextTokens };
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
if (trimmed === "/help") {
|
|
@@ -120,6 +122,12 @@ export async function handleSlashCommand(trimmed, {
|
|
|
120
122
|
return { handled: true };
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
if (trimmed === "/notify") {
|
|
126
|
+
const result = await runner.notifyTest?.();
|
|
127
|
+
ui.writeln(formatNotificationResult(result));
|
|
128
|
+
return { handled: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
123
131
|
const shellCommand = parseShellCommand(trimmed);
|
|
124
132
|
if (shellCommand.type !== "none") {
|
|
125
133
|
for (const line of handleShellCommand(shellCommand, { shellRuntime: runner.shellRuntime })) ui.writeln(line);
|
|
@@ -174,3 +182,11 @@ export async function handleSlashCommand(trimmed, {
|
|
|
174
182
|
|
|
175
183
|
return { handled: false };
|
|
176
184
|
}
|
|
185
|
+
|
|
186
|
+
function formatNotificationResult(result) {
|
|
187
|
+
if (!result) return "notification: unavailable";
|
|
188
|
+
const channels = (result.results ?? [])
|
|
189
|
+
.map((entry) => `${entry.channel}:${entry.ok ? "ok" : entry.reason ?? "failed"}`)
|
|
190
|
+
.join(", ");
|
|
191
|
+
return `notification: ${result.ok ? "ok" : result.reason ?? "failed"}${channels ? ` (${channels})` : ""}`;
|
|
192
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { parseMouseEvent } from "./mouse-tracking.mjs";
|
|
2
2
|
import { brightBlack } from "../ui-theme.mjs";
|
|
3
3
|
|
|
4
|
+
const OUTPUT_WHEEL_SCROLL_LINES = 3;
|
|
5
|
+
|
|
4
6
|
export function createMouseSelectionController({
|
|
5
7
|
terminal,
|
|
6
8
|
output,
|
|
@@ -49,7 +51,7 @@ export function createMouseSelectionController({
|
|
|
49
51
|
if (shellDrawer.isVisible?.() && mouse.col > Math.floor((terminal.columns || 80) * 0.64)) {
|
|
50
52
|
shellDrawerControls.scroll(mouse.delta);
|
|
51
53
|
} else {
|
|
52
|
-
output.scroll(mouse.delta);
|
|
54
|
+
output.scroll(mouse.delta, { step: OUTPUT_WHEEL_SCROLL_LINES });
|
|
53
55
|
}
|
|
54
56
|
requestRender();
|
|
55
57
|
return { consume: true };
|
|
@@ -17,11 +17,11 @@ export class OutputScrollState {
|
|
|
17
17
|
this.totalLines = Math.max(0, Math.trunc(total));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
scroll(delta) {
|
|
20
|
+
scroll(delta, { step = this.getStep() } = {}) {
|
|
21
21
|
const total = this.totalLines;
|
|
22
22
|
const win = this._windowHeight();
|
|
23
23
|
const maxOffset = this.getMaxOffset();
|
|
24
|
-
|
|
24
|
+
step = Math.max(1, Math.trunc(step));
|
|
25
25
|
const currentStart = this.anchorStart ?? Math.max(0, total - this.offset - win);
|
|
26
26
|
const maxStart = Math.max(0, total - win);
|
|
27
27
|
const nextStart = clamp(currentStart + (delta < 0 ? -step : step), 0, maxStart);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { R } from "../ui-theme.mjs";
|
|
3
|
+
|
|
4
|
+
export function appendTextLines(lines, textLines, width) {
|
|
5
|
+
for (const line of textLines) {
|
|
6
|
+
for (const part of String(line ?? "").split(/\r?\n/)) {
|
|
7
|
+
for (const wrapped of wrapLine(part, width)) lines.push(wrapped);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function wrapLine(text, maxWidth) {
|
|
13
|
+
if (maxWidth <= 0) return [""];
|
|
14
|
+
const result = [];
|
|
15
|
+
let cur = "";
|
|
16
|
+
let curW = 0;
|
|
17
|
+
let activeSgr = "";
|
|
18
|
+
for (let i = 0; i < text.length;) {
|
|
19
|
+
if (text[i] === "\x1b") {
|
|
20
|
+
const match = text.slice(i).match(/^\x1b\[[0-?]*[ -/]*[@-~]/);
|
|
21
|
+
if (match) {
|
|
22
|
+
const seq = match[0];
|
|
23
|
+
cur += seq;
|
|
24
|
+
activeSgr = updateActiveSgr(activeSgr, seq);
|
|
25
|
+
i += seq.length;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const ch = text[i];
|
|
30
|
+
const w = visibleWidth(ch);
|
|
31
|
+
if (curW + w > maxWidth) {
|
|
32
|
+
result.push(activeSgr ? `${cur}${R}` : cur);
|
|
33
|
+
cur = activeSgr + ch;
|
|
34
|
+
curW = w;
|
|
35
|
+
} else {
|
|
36
|
+
cur += ch;
|
|
37
|
+
curW += w;
|
|
38
|
+
}
|
|
39
|
+
i += 1;
|
|
40
|
+
}
|
|
41
|
+
if (cur) result.push(cur);
|
|
42
|
+
return result.length > 0 ? result : [""];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function updateActiveSgr(activeSgr, seq) {
|
|
46
|
+
if (!seq.endsWith("m")) return activeSgr;
|
|
47
|
+
const body = seq.slice(2, -1);
|
|
48
|
+
if (body === "" || body.split(";").includes("0")) return "";
|
|
49
|
+
return seq;
|
|
50
|
+
}
|
|
@@ -1,59 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { R, brightBlack, dim } from "./ui-theme.mjs";
|
|
1
|
+
import { brightBlack, dim } from "./ui-theme.mjs";
|
|
3
2
|
import { renderToolCardBlock } from "./output/tool-card-renderer.mjs";
|
|
4
3
|
import { renderMarkdown, renderStreamingMarkdown } from "./markdown-renderer.mjs";
|
|
5
4
|
import { renderEditDiffBlock } from "./tui-diff-rendering.mjs";
|
|
6
5
|
import { OutputScrollState } from "./output/scroll-state.mjs";
|
|
6
|
+
import { appendTextLines, wrapLine } from "./output/text-line-renderer.mjs";
|
|
7
7
|
|
|
8
8
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
9
|
|
|
10
|
-
function wrapLine(text, maxWidth) {
|
|
11
|
-
if (maxWidth <= 0) return [""];
|
|
12
|
-
const result = [];
|
|
13
|
-
let cur = "";
|
|
14
|
-
let curW = 0;
|
|
15
|
-
let activeSgr = "";
|
|
16
|
-
for (let i = 0; i < text.length;) {
|
|
17
|
-
if (text[i] === "\x1b") {
|
|
18
|
-
const match = text.slice(i).match(/^\x1b\[[0-?]*[ -/]*[@-~]/);
|
|
19
|
-
if (match) {
|
|
20
|
-
const seq = match[0];
|
|
21
|
-
cur += seq;
|
|
22
|
-
activeSgr = updateActiveSgr(activeSgr, seq);
|
|
23
|
-
i += seq.length;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
const ch = text[i];
|
|
28
|
-
const w = visibleWidth(ch);
|
|
29
|
-
if (curW + w > maxWidth) {
|
|
30
|
-
result.push(activeSgr ? `${cur}${R}` : cur);
|
|
31
|
-
cur = activeSgr + ch;
|
|
32
|
-
curW = w;
|
|
33
|
-
} else {
|
|
34
|
-
cur += ch;
|
|
35
|
-
curW += w;
|
|
36
|
-
}
|
|
37
|
-
i += 1;
|
|
38
|
-
}
|
|
39
|
-
if (cur) result.push(cur);
|
|
40
|
-
return result.length > 0 ? result : [""];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function updateActiveSgr(activeSgr, seq) {
|
|
44
|
-
if (!seq.endsWith("m")) return activeSgr;
|
|
45
|
-
const body = seq.slice(2, -1);
|
|
46
|
-
if (body === "" || body.split(";").includes("0")) return "";
|
|
47
|
-
return seq;
|
|
48
|
-
}
|
|
49
10
|
|
|
50
|
-
function appendTextLines(lines, textLines, width) {
|
|
51
|
-
for (const line of textLines) {
|
|
52
|
-
for (const part of String(line ?? "").split(/\r?\n/)) {
|
|
53
|
-
for (const wrapped of wrapLine(part, width)) lines.push(wrapped);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
11
|
|
|
58
12
|
function currentTextToBlocks(textLines, sealed, cache = null) {
|
|
59
13
|
const blocks = [];
|
|
@@ -117,6 +71,7 @@ export class OutputBuffer {
|
|
|
117
71
|
this._activeThinking = null;
|
|
118
72
|
this.overlayStatus = null;
|
|
119
73
|
this.scrollState = new OutputScrollState();
|
|
74
|
+
this._segmentLinesCache = new Map();
|
|
120
75
|
}
|
|
121
76
|
|
|
122
77
|
get scrollOffset() {
|
|
@@ -133,6 +88,7 @@ export class OutputBuffer {
|
|
|
133
88
|
this._activeThinking = null;
|
|
134
89
|
this.overlayStatus = null;
|
|
135
90
|
this.scrollState.clear();
|
|
91
|
+
this._segmentLinesCache = new Map();
|
|
136
92
|
}
|
|
137
93
|
|
|
138
94
|
write(text) {
|
|
@@ -174,6 +130,7 @@ export class OutputBuffer {
|
|
|
174
130
|
this._flushText();
|
|
175
131
|
const seg = { type: "thinking", tokens: 0, content: [] };
|
|
176
132
|
this.segments.push(seg);
|
|
133
|
+
this._invalidateSegmentLines();
|
|
177
134
|
this._activeThinking = seg;
|
|
178
135
|
}
|
|
179
136
|
|
|
@@ -197,12 +154,14 @@ export class OutputBuffer {
|
|
|
197
154
|
this.overlayStatus = null;
|
|
198
155
|
this._flushText();
|
|
199
156
|
this.segments.push({ type: "thinking", tokens, content: content.split("\n") });
|
|
157
|
+
this._invalidateSegmentLines();
|
|
200
158
|
}
|
|
201
159
|
|
|
202
160
|
addBlock(block) {
|
|
203
161
|
this.overlayStatus = null;
|
|
204
162
|
this._flushText();
|
|
205
163
|
this.segments.push(block);
|
|
164
|
+
this._invalidateSegmentLines();
|
|
206
165
|
}
|
|
207
166
|
|
|
208
167
|
setOverlayStatus(lines) {
|
|
@@ -220,6 +179,7 @@ export class OutputBuffer {
|
|
|
220
179
|
_flushText() {
|
|
221
180
|
if (this.currentText.length <= 1 && this.currentText[0].text === "") return false;
|
|
222
181
|
this.segments.push(...currentTextToBlocks(this.currentText, true));
|
|
182
|
+
this._invalidateSegmentLines();
|
|
223
183
|
this.currentText = [{ text: "", markdown: false }];
|
|
224
184
|
this.currentTextCache = new Map();
|
|
225
185
|
return true;
|
|
@@ -234,8 +194,8 @@ export class OutputBuffer {
|
|
|
234
194
|
this.spinnerIdx = (this.spinnerIdx + 1) % SPINNER_FRAMES.length;
|
|
235
195
|
}
|
|
236
196
|
|
|
237
|
-
scroll(delta) {
|
|
238
|
-
return this.scrollState.scroll(delta);
|
|
197
|
+
scroll(delta, options) {
|
|
198
|
+
return this.scrollState.scroll(delta, options);
|
|
239
199
|
}
|
|
240
200
|
|
|
241
201
|
getScrollStep() {
|
|
@@ -262,10 +222,17 @@ export class OutputBuffer {
|
|
|
262
222
|
seg.expanded = expanded;
|
|
263
223
|
changed = true;
|
|
264
224
|
}
|
|
225
|
+
if (changed) this._invalidateSegmentLines();
|
|
265
226
|
return changed;
|
|
266
227
|
}
|
|
267
228
|
|
|
268
|
-
invalidate() {
|
|
229
|
+
invalidate() {
|
|
230
|
+
this._invalidateSegmentLines();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_invalidateSegmentLines() {
|
|
234
|
+
this._segmentLinesCache.clear();
|
|
235
|
+
}
|
|
269
236
|
|
|
270
237
|
render(width) {
|
|
271
238
|
const allLines = this._computeLines(width);
|
|
@@ -278,8 +245,9 @@ export class OutputBuffer {
|
|
|
278
245
|
}
|
|
279
246
|
|
|
280
247
|
_computeLines(width) {
|
|
281
|
-
const lines = [];
|
|
282
|
-
|
|
248
|
+
const lines = [...this._renderCachedSegmentLines(width)];
|
|
249
|
+
const dynamicStart = this._cachedSegmentPrefixCount();
|
|
250
|
+
for (const seg of this.segments.slice(dynamicStart)) {
|
|
283
251
|
for (const line of renderBlock(seg, width)) lines.push(line);
|
|
284
252
|
}
|
|
285
253
|
for (const block of currentTextToBlocks(this.currentText, false, this.currentTextCache)) {
|
|
@@ -294,4 +262,23 @@ export class OutputBuffer {
|
|
|
294
262
|
}
|
|
295
263
|
return lines;
|
|
296
264
|
}
|
|
265
|
+
|
|
266
|
+
_renderCachedSegmentLines(width) {
|
|
267
|
+
const prefixCount = this._cachedSegmentPrefixCount();
|
|
268
|
+
const cached = this._segmentLinesCache.get(width);
|
|
269
|
+
if (cached?.prefixCount === prefixCount) return cached.lines;
|
|
270
|
+
|
|
271
|
+
const lines = [];
|
|
272
|
+
for (let i = 0; i < prefixCount; i += 1) {
|
|
273
|
+
for (const line of renderBlock(this.segments[i], width)) lines.push(line);
|
|
274
|
+
}
|
|
275
|
+
this._segmentLinesCache.set(width, { prefixCount, lines });
|
|
276
|
+
return lines;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_cachedSegmentPrefixCount() {
|
|
280
|
+
if (!this._activeThinking) return this.segments.length;
|
|
281
|
+
const index = this.segments.indexOf(this._activeThinking);
|
|
282
|
+
return index < 0 ? this.segments.length : index;
|
|
283
|
+
}
|
|
297
284
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createRenderScheduler({ requestRender, delayMs = 50 }) {
|
|
2
|
+
let timer = null;
|
|
3
|
+
|
|
4
|
+
function renderNow() {
|
|
5
|
+
clearPending();
|
|
6
|
+
requestRender();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function renderSoon() {
|
|
10
|
+
if (timer) return;
|
|
11
|
+
// Streaming deltas are append-only, so coalesce them without delaying input-driven renders.
|
|
12
|
+
timer = setTimeout(() => {
|
|
13
|
+
timer = null;
|
|
14
|
+
requestRender();
|
|
15
|
+
}, delayMs);
|
|
16
|
+
timer.unref?.();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clearPending() {
|
|
20
|
+
if (!timer) return;
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
timer = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { renderNow, renderSoon, clearPending };
|
|
26
|
+
}
|
|
@@ -90,6 +90,9 @@ export function formatToolSuccessSummary(name, result, out = "") {
|
|
|
90
90
|
const matches = result?.details?.count ?? countNonEmptyLines(out);
|
|
91
91
|
return `${matches} file${matches === 1 ? "" : "s"}`;
|
|
92
92
|
}
|
|
93
|
+
if (name === "memory_open") {
|
|
94
|
+
return compactText(result?.details?.entry?.name ?? compactPath(result?.details?.path ?? ""));
|
|
95
|
+
}
|
|
93
96
|
return "";
|
|
94
97
|
}
|
|
95
98
|
|
package/src/cli/ui.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { createTuiInputController } from "./tui/tui-input-controller.mjs";
|
|
|
23
23
|
import { writeMemoryHint } from "./tui/recall-rendering.mjs";
|
|
24
24
|
import { writeToolEnd, writeToolStart } from "./tui/tool-rendering.mjs";
|
|
25
25
|
import { EDITOR_THEME, brightBlack } from "./tui/ui-theme.mjs";
|
|
26
|
+
import { createRenderScheduler } from "./tui/render/render-scheduler.mjs";
|
|
26
27
|
import { writeTranscriptToOutput } from "../session/transcript.mjs";
|
|
27
28
|
|
|
28
29
|
export { buildMarchCommands, MarchAutocompleteProvider } from "./input/autocomplete.mjs";
|
|
@@ -59,10 +60,8 @@ export function createTuiUI({
|
|
|
59
60
|
let mouseOn = true;
|
|
60
61
|
let toolsExpanded = false;
|
|
61
62
|
const activeToolBlocks = [];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
tui.requestRender();
|
|
65
|
-
}
|
|
63
|
+
const renderScheduler = createRenderScheduler({ requestRender: () => tui.requestRender() });
|
|
64
|
+
const requestRender = renderScheduler.renderNow;
|
|
66
65
|
|
|
67
66
|
const spinnerStatus = createSpinnerStatusController({ output, requestRender });
|
|
68
67
|
const retryStatus = createRetryStatusController({ output, requestRender, stopSpinner: spinnerStatus.stop });
|
|
@@ -172,7 +171,7 @@ export function createTuiUI({
|
|
|
172
171
|
|
|
173
172
|
thinkingDelta: (delta) => {
|
|
174
173
|
output.appendThinking(delta);
|
|
175
|
-
|
|
174
|
+
renderScheduler.renderSoon();
|
|
176
175
|
},
|
|
177
176
|
|
|
178
177
|
thinkingEnd: (tokens) => {
|
|
@@ -197,7 +196,7 @@ export function createTuiUI({
|
|
|
197
196
|
textDelta: (delta) => {
|
|
198
197
|
ensureStarted(); retryStatus.stop(); spinnerStatus.stop();
|
|
199
198
|
output.writeMarkdown(delta);
|
|
200
|
-
|
|
199
|
+
renderScheduler.renderSoon();
|
|
201
200
|
},
|
|
202
201
|
assistantReplyEnd: () => {
|
|
203
202
|
ensureStarted();
|
|
@@ -278,8 +277,8 @@ export function createTuiUI({
|
|
|
278
277
|
toggleToolOutput,
|
|
279
278
|
toggleShellDrawer: () => shellDrawerControls.toggle(),
|
|
280
279
|
requestExit: () => inputController.requestExit(),
|
|
281
|
-
|
|
282
280
|
close: async () => {
|
|
281
|
+
renderScheduler.clearPending();
|
|
283
282
|
spinnerStatus.stop();
|
|
284
283
|
retryStatus.stop();
|
|
285
284
|
if (started) {
|
package/src/config/loader.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function mergeLayers(layers) {
|
|
|
49
49
|
maxTurns: null,
|
|
50
50
|
trimBatch: null,
|
|
51
51
|
memoryRoot: null,
|
|
52
|
-
notifications: { turnEnd: true },
|
|
52
|
+
notifications: { turnEnd: true, desktop: true, bell: false, command: null, minDurationMs: 0, sound: true },
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
for (const layer of layers) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function defaultCenterMemoryPath() {
|
|
6
|
+
return join(homedir(), ".march", "memory", "center.md");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildCenterMemory(path = defaultCenterMemoryPath()) {
|
|
10
|
+
if (!path || !existsSync(path)) return null;
|
|
11
|
+
const content = readFileSync(path, "utf8").trimEnd();
|
|
12
|
+
if (!content.trim()) return null;
|
|
13
|
+
return `[center_memory]\n--- ${path} ---\n${content}`;
|
|
14
|
+
}
|
package/src/context/engine.mjs
CHANGED
|
@@ -3,12 +3,14 @@ import { buildSessionIdentity } from "./session-status.mjs";
|
|
|
3
3
|
import { buildSystemCore, resolveSystemCorePromptKey } from "./system-core.mjs";
|
|
4
4
|
import { buildInjectionsLayer } from "./injections.mjs";
|
|
5
5
|
import { buildProjectContext } from "./project-context.mjs";
|
|
6
|
+
import { buildCenterMemory } from "./center-memory.mjs";
|
|
6
7
|
import { formatRecallHints } from "../memory/markdown-store.mjs";
|
|
7
8
|
|
|
8
9
|
export class ContextEngine {
|
|
9
|
-
constructor({ cwd, modelId, provider = "deepseek", thinkingLevel = "medium", namespace = "", memoryRoot = null, shellRuntime = null, lspService = null, injections = [], maxTurns, trimBatch }) {
|
|
10
|
+
constructor({ cwd, modelId, provider = "deepseek", thinkingLevel = "medium", namespace = "", memoryRoot = null, centerMemoryPath = null, shellRuntime = null, lspService = null, injections = [], maxTurns, trimBatch }) {
|
|
10
11
|
this.cwd = cwd;
|
|
11
12
|
this.memoryRoot = memoryRoot;
|
|
13
|
+
this.centerMemoryPath = centerMemoryPath;
|
|
12
14
|
this.modelId = modelId;
|
|
13
15
|
this.provider = provider;
|
|
14
16
|
this.thinkingLevel = thinkingLevel;
|
|
@@ -60,6 +62,9 @@ export class ContextEngine {
|
|
|
60
62
|
const projectCtx = buildProjectContext(this.cwd);
|
|
61
63
|
if (projectCtx) layers.push({ name: "project_context", text: projectCtx });
|
|
62
64
|
|
|
65
|
+
const centerMemory = buildCenterMemory(this.centerMemoryPath);
|
|
66
|
+
if (centerMemory) layers.push({ name: "center_memory", text: centerMemory });
|
|
67
|
+
|
|
63
68
|
layers.push({ name: "recent_chat", text: this.#buildRecentChat() });
|
|
64
69
|
|
|
65
70
|
return layers;
|
|
@@ -7,7 +7,7 @@ The user primarily asks for software engineering work: fixing bugs, adding behav
|
|
|
7
7
|
- Be concise and direct. Match the response shape to the task; simple questions get simple answers.
|
|
8
8
|
- Assume users may not see tool calls. Before the first tool call, say in one sentence what you are about to do. While working, give brief updates when you find something important, change direction, or hit a blocker.
|
|
9
9
|
- Don't narrate hidden reasoning. State decisions, results, and relevant next steps.
|
|
10
|
-
- End with
|
|
10
|
+
- End with a brief summary of what you did during the task, including what changed, verification status, and what's next if anything; keep it concise, but don't omit the execution overview.
|
|
11
11
|
- Report outcomes truthfully. If tests fail or a step was skipped, say so plainly with the relevant output or reason.
|
|
12
12
|
</communication_contract>
|
|
13
13
|
|
|
@@ -57,4 +57,8 @@ The user primarily asks for software engineering work: fixing bugs, adding behav
|
|
|
57
57
|
- Use memory_search(query) for full-text search across all memories.
|
|
58
58
|
- To edit an existing memory, use memory_open(id) to get its path, then edit_file with mode="patch" for targeted edits.
|
|
59
59
|
- Use memory_save() to create memories or update whole fields. 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'.
|
|
60
|
+
- 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.
|
|
61
|
+
- Distinguish "migrating a Skill to memory" from "learning a Skill": migration preserves the complete Skill folder under memory_root/skills/ and creates a memory index with purpose, source, entry files, and local path; 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.
|
|
62
|
+
- Unlike memory hints, 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.
|
|
63
|
+
- 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.
|
|
60
64
|
</memory_system>
|
package/src/main.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { formatStartupBanner } from "./cli/startup/startup-banner.mjs";
|
|
|
28
28
|
import { initializeMcp } from "./mcp/index.mjs";
|
|
29
29
|
import { createWebToolsFromConfig } from "./web/tools.mjs";
|
|
30
30
|
import { createModelContextDumper } from "./debug/model-context-dumper.mjs";
|
|
31
|
+
import { defaultCenterMemoryPath } from "./context/center-memory.mjs";
|
|
31
32
|
import { runProviderConfigCommand } from "./provider/config-command.mjs";
|
|
32
33
|
import { runWebSearchConfigCommand } from "./web/config-command.mjs";
|
|
33
34
|
import { createDesktopTurnNotifier } from "./notification/desktop-notifier.mjs";
|
|
@@ -91,6 +92,7 @@ export async function run(argv) {
|
|
|
91
92
|
const modeState = createModeState();
|
|
92
93
|
const namespace = loadOrCreateProjectId(projectMarchDir);
|
|
93
94
|
const memoryRoot = resolveMemoryRoot(config.memoryRoot, stateRoot);
|
|
95
|
+
const centerMemoryPath = defaultCenterMemoryPath();
|
|
94
96
|
const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
|
|
95
97
|
const memoryTools = createMarkdownMemoryTools(memoryStore);
|
|
96
98
|
const currentProject = basename(cwd);
|
|
@@ -111,6 +113,7 @@ export async function run(argv) {
|
|
|
111
113
|
const webTools = createWebToolsFromConfig(config);
|
|
112
114
|
const turnNotifier = createDesktopTurnNotifier({
|
|
113
115
|
enabled: Boolean(config.notifications?.turnEnd),
|
|
116
|
+
config: config.notifications,
|
|
114
117
|
});
|
|
115
118
|
|
|
116
119
|
// Permission controller
|
|
@@ -156,6 +159,7 @@ export async function run(argv) {
|
|
|
156
159
|
stateRoot,
|
|
157
160
|
ui,
|
|
158
161
|
memoryRoot,
|
|
162
|
+
centerMemoryPath,
|
|
159
163
|
memoryStore,
|
|
160
164
|
memoryTools,
|
|
161
165
|
shellRuntime,
|
|
@@ -1,42 +1,101 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import nodeNotifier from "node-notifier";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
|
|
3
5
|
const DEFAULT_BALLOON_TIMEOUT_MS = 5000;
|
|
6
|
+
const DEFAULT_NOTIFICATION_ICON_PATH = fileURLToPath(new URL("../assets/march-icon.png", import.meta.url));
|
|
4
7
|
|
|
5
8
|
export function createDesktopTurnNotifier({
|
|
6
9
|
enabled = true,
|
|
7
10
|
platform = process.platform,
|
|
8
11
|
spawnProcess = spawn,
|
|
12
|
+
writeBell = () => process.stdout.write("\x07"),
|
|
13
|
+
toastNotifier = nodeNotifier,
|
|
14
|
+
config = {},
|
|
9
15
|
} = {}) {
|
|
16
|
+
const channels = resolveNotificationChannels(config);
|
|
17
|
+
const minDurationMs = normalizeNonNegativeInteger(config.minDurationMs, 0);
|
|
18
|
+
const toastSound = normalizeNotificationSound(config.sound, true);
|
|
10
19
|
return {
|
|
11
20
|
async notifyTurnEnd(event) {
|
|
12
|
-
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
const normalizedEvent = normalizeTurnEvent(event);
|
|
22
|
+
if (!enabled) return { ok: false, reason: "disabled", results: [] };
|
|
23
|
+
if (normalizedEvent.durationMs < minDurationMs) return { ok: false, reason: "min-duration", results: [] };
|
|
24
|
+
|
|
25
|
+
const payload = {
|
|
26
|
+
title: normalizedEvent.title ?? defaultTurnTitle(normalizedEvent.status),
|
|
27
|
+
message: normalizedEvent.message ?? defaultTurnMessage(normalizedEvent),
|
|
28
|
+
sound: toastSound,
|
|
29
|
+
};
|
|
30
|
+
const results = [];
|
|
31
|
+
if (channels.desktop) {
|
|
32
|
+
results.push({
|
|
33
|
+
channel: "desktop",
|
|
34
|
+
...(await sendDesktopNotification({ platform, spawnProcess, toastNotifier, ...payload })),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (channels.bell) results.push({ channel: "bell", ...sendBellNotification({ writeBell }) });
|
|
38
|
+
if (channels.command) {
|
|
39
|
+
results.push({
|
|
40
|
+
channel: "command",
|
|
41
|
+
...sendCommandNotification({ spawnProcess, command: channels.command, event: normalizedEvent, ...payload }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const delivered = results.some((result) => result.ok);
|
|
46
|
+
return {
|
|
47
|
+
ok: delivered,
|
|
48
|
+
reason: delivered ? undefined : results[0]?.reason ?? "no-channels",
|
|
49
|
+
results,
|
|
50
|
+
};
|
|
19
51
|
},
|
|
20
52
|
};
|
|
21
53
|
}
|
|
22
54
|
|
|
23
|
-
export async function sendDesktopNotification({ platform = process.platform, spawnProcess = spawn, title, message }) {
|
|
55
|
+
export async function sendDesktopNotification({ platform = process.platform, spawnProcess = spawn, toastNotifier = nodeNotifier, title, message, iconPath = DEFAULT_NOTIFICATION_ICON_PATH, sound = true }) {
|
|
24
56
|
if (platform !== "win32") return { ok: false, reason: "unsupported-platform" };
|
|
25
57
|
|
|
26
58
|
const safeTitle = normalizeNotificationText(title) || "March";
|
|
27
59
|
const safeMessage = normalizeNotificationText(message) || "Turn finished";
|
|
28
|
-
const script = buildWindowsNotificationScript({ title: safeTitle, message: safeMessage });
|
|
29
60
|
|
|
30
61
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
const toastResult = await sendWindowsToastNotification({ toastNotifier, title: safeTitle, message: safeMessage, iconPath, sound });
|
|
63
|
+
if (toastResult.ok) return { ok: true };
|
|
64
|
+
|
|
65
|
+
const script = buildWindowsNotificationScript({ title: safeTitle, message: safeMessage, iconPath });
|
|
66
|
+
const balloonResult = await runWindowsNotificationPowerShell({ spawnProcess, script, timeoutMs: DEFAULT_BALLOON_TIMEOUT_MS + 5000 });
|
|
67
|
+
if (balloonResult.ok) return { ok: true, fallback: "balloon", toastReason: toastResult.reason };
|
|
68
|
+
return { ok: false, reason: `toast: ${toastResult.reason}; balloon: ${balloonResult.reason}` };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sendBellNotification({ writeBell = () => process.stdout.write("\x07") } = {}) {
|
|
75
|
+
try {
|
|
76
|
+
writeBell("\x07");
|
|
77
|
+
return { ok: true };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sendCommandNotification({ spawnProcess = spawn, command, event = {}, title, message }) {
|
|
84
|
+
if (!command) return { ok: false, reason: "command-not-configured" };
|
|
85
|
+
try {
|
|
86
|
+
const child = spawnProcess(String(command), [], {
|
|
87
|
+
shell: true,
|
|
37
88
|
detached: true,
|
|
38
89
|
stdio: "ignore",
|
|
39
90
|
windowsHide: true,
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
MARCH_NOTIFICATION_STATUS: String(event.status ?? ""),
|
|
94
|
+
MARCH_NOTIFICATION_TITLE: normalizeNotificationText(title),
|
|
95
|
+
MARCH_NOTIFICATION_MESSAGE: normalizeNotificationText(message),
|
|
96
|
+
MARCH_NOTIFICATION_SESSION: normalizeNotificationText(event.sessionName),
|
|
97
|
+
MARCH_NOTIFICATION_DURATION_MS: String(event.durationMs ?? 0),
|
|
98
|
+
},
|
|
40
99
|
});
|
|
41
100
|
child?.unref?.();
|
|
42
101
|
return { ok: true };
|
|
@@ -45,63 +104,138 @@ export async function sendDesktopNotification({ platform = process.platform, spa
|
|
|
45
104
|
}
|
|
46
105
|
}
|
|
47
106
|
|
|
48
|
-
export function buildWindowsBalloonScript({ title, message, timeoutMs = DEFAULT_BALLOON_TIMEOUT_MS }) {
|
|
107
|
+
export function buildWindowsBalloonScript({ title, message, timeoutMs = DEFAULT_BALLOON_TIMEOUT_MS, iconPath = DEFAULT_NOTIFICATION_ICON_PATH }) {
|
|
49
108
|
const escapedTitle = escapePowerShellSingleQuotedString(title);
|
|
50
109
|
const escapedMessage = escapePowerShellSingleQuotedString(message);
|
|
110
|
+
const escapedIconPath = escapePowerShellSingleQuotedString(iconPath);
|
|
51
111
|
const timeout = Number.isFinite(timeoutMs) ? Math.max(0, Math.trunc(timeoutMs)) : DEFAULT_BALLOON_TIMEOUT_MS;
|
|
52
112
|
return [
|
|
53
113
|
"Add-Type -AssemblyName System.Windows.Forms",
|
|
54
114
|
"Add-Type -AssemblyName System.Drawing",
|
|
55
115
|
"$n = New-Object System.Windows.Forms.NotifyIcon",
|
|
56
|
-
|
|
116
|
+
`$sourceBitmap = [System.Drawing.Bitmap]::FromFile('${escapedIconPath}')`,
|
|
117
|
+
"$trayBitmap = New-Object System.Drawing.Bitmap 32, 32",
|
|
118
|
+
"$graphics = [System.Drawing.Graphics]::FromImage($trayBitmap)",
|
|
119
|
+
"$graphics.Clear([System.Drawing.Color]::Transparent)",
|
|
120
|
+
"$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
|
|
121
|
+
"$graphics.DrawImage($sourceBitmap, 0, 0, 32, 32)",
|
|
122
|
+
"$icon = [System.Drawing.Icon]::FromHandle($trayBitmap.GetHicon())",
|
|
123
|
+
"$n.Icon = $icon",
|
|
124
|
+
"$n.Text = 'March'",
|
|
125
|
+
"$n.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::None",
|
|
57
126
|
`$n.BalloonTipTitle = '${escapedTitle}'`,
|
|
58
127
|
`$n.BalloonTipText = '${escapedMessage}'`,
|
|
59
128
|
"$n.Visible = $true",
|
|
60
129
|
`$n.ShowBalloonTip(${timeout})`,
|
|
61
130
|
`Start-Sleep -Milliseconds ${timeout + 500}`,
|
|
62
131
|
"$n.Dispose()",
|
|
132
|
+
"$icon.Dispose()",
|
|
133
|
+
"$graphics.Dispose()",
|
|
134
|
+
"$trayBitmap.Dispose()",
|
|
135
|
+
"$sourceBitmap.Dispose()",
|
|
63
136
|
].join("; ");
|
|
64
137
|
}
|
|
65
138
|
|
|
66
|
-
export function buildWindowsNotificationScript({ title, message, timeoutMs = DEFAULT_BALLOON_TIMEOUT_MS }) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"}",
|
|
80
|
-
].join("; ");
|
|
139
|
+
export function buildWindowsNotificationScript({ title, message, timeoutMs = DEFAULT_BALLOON_TIMEOUT_MS, iconPath = DEFAULT_NOTIFICATION_ICON_PATH }) {
|
|
140
|
+
return buildWindowsBalloonScript({ title, message, timeoutMs, iconPath });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildWindowsToastOptions({ title, message, iconPath = DEFAULT_NOTIFICATION_ICON_PATH, sound = true }) {
|
|
144
|
+
return {
|
|
145
|
+
title,
|
|
146
|
+
message,
|
|
147
|
+
icon: iconPath,
|
|
148
|
+
appID: "March",
|
|
149
|
+
sound,
|
|
150
|
+
wait: false,
|
|
151
|
+
};
|
|
81
152
|
}
|
|
82
153
|
|
|
83
|
-
function
|
|
84
|
-
return
|
|
154
|
+
function sendWindowsToastNotification({ toastNotifier = nodeNotifier, title, message, iconPath, sound }) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const notify = toastNotifier?.notify;
|
|
157
|
+
if (typeof notify !== "function") {
|
|
158
|
+
resolve({ ok: false, reason: "toast-notifier-unavailable" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let settled = false;
|
|
163
|
+
const timeout = setTimeout(() => finish({ ok: false, reason: "toast-timeout" }), DEFAULT_BALLOON_TIMEOUT_MS + 5000);
|
|
164
|
+
notify.call(toastNotifier, buildWindowsToastOptions({ title, message, iconPath, sound }), (err) => {
|
|
165
|
+
if (err) {
|
|
166
|
+
finish({ ok: false, reason: err?.message ?? String(err) });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
finish({ ok: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function finish(result) {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
settled = true;
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
resolve(result);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
85
179
|
}
|
|
86
180
|
|
|
87
|
-
function
|
|
88
|
-
return
|
|
89
|
-
.
|
|
90
|
-
.
|
|
91
|
-
.
|
|
181
|
+
function resolveNotificationChannels(config) {
|
|
182
|
+
return {
|
|
183
|
+
desktop: config.desktop !== false,
|
|
184
|
+
bell: config.bell === true,
|
|
185
|
+
command: typeof config.command === "string" && config.command.trim() ? config.command.trim() : null,
|
|
186
|
+
};
|
|
92
187
|
}
|
|
93
188
|
|
|
94
|
-
function
|
|
95
|
-
return
|
|
189
|
+
function runWindowsNotificationPowerShell({ spawnProcess, script, timeoutMs }) {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
const child = spawnProcess("powershell.exe", [
|
|
192
|
+
"-NoProfile",
|
|
193
|
+
"-ExecutionPolicy", "Bypass",
|
|
194
|
+
"-Command", script,
|
|
195
|
+
], {
|
|
196
|
+
windowsHide: false,
|
|
197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let stderr = "";
|
|
201
|
+
let settled = false;
|
|
202
|
+
const timeout = setTimeout(() => finish({ ok: false, reason: "timeout" }), timeoutMs);
|
|
203
|
+
|
|
204
|
+
child?.stderr?.on?.("data", (chunk) => { stderr += chunk; });
|
|
205
|
+
child?.on?.("error", (err) => finish({ ok: false, reason: err?.message ?? String(err) }));
|
|
206
|
+
child?.on?.("close", (exitCode, signal) => {
|
|
207
|
+
if (exitCode === 0) {
|
|
208
|
+
finish({ ok: true });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const detail = stderr.trim() || (signal ? `signal ${signal}` : `exit ${exitCode}`);
|
|
212
|
+
finish({ ok: false, reason: detail });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
function finish(result) {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
clearTimeout(timeout);
|
|
219
|
+
resolve(result);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeTurnEvent(event) {
|
|
225
|
+
return {
|
|
226
|
+
...event,
|
|
227
|
+
status: event?.status === "error" ? "error" : "success",
|
|
228
|
+
durationMs: normalizeNonNegativeInteger(event?.durationMs, 0),
|
|
229
|
+
};
|
|
96
230
|
}
|
|
97
231
|
|
|
98
|
-
function defaultTurnTitle(
|
|
99
|
-
return
|
|
232
|
+
function defaultTurnTitle() {
|
|
233
|
+
return "March";
|
|
100
234
|
}
|
|
101
235
|
|
|
102
236
|
function defaultTurnMessage(event) {
|
|
103
237
|
if (event?.status === "error") return event?.errorMessage ?? "Something went wrong";
|
|
104
|
-
return event?.
|
|
238
|
+
return event?.draft || "Turn finished";
|
|
105
239
|
}
|
|
106
240
|
|
|
107
241
|
function normalizeNotificationText(text) {
|
|
@@ -112,6 +246,17 @@ function normalizeNotificationText(text) {
|
|
|
112
246
|
.slice(0, 240);
|
|
113
247
|
}
|
|
114
248
|
|
|
249
|
+
function normalizeNotificationSound(value, fallback) {
|
|
250
|
+
if (value === false) return false;
|
|
251
|
+
if (typeof value === "string") return normalizeNotificationText(value) || fallback;
|
|
252
|
+
return value === undefined ? fallback : Boolean(value);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeNonNegativeInteger(value, fallback) {
|
|
256
|
+
const number = Number(value);
|
|
257
|
+
return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : fallback;
|
|
258
|
+
}
|
|
259
|
+
|
|
115
260
|
function escapePowerShellSingleQuotedString(text) {
|
|
116
261
|
return String(text).replaceAll("'", "''");
|
|
117
262
|
}
|
|
@@ -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
|
+
}
|