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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.3",
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"
@@ -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 { userRecallHints = [], currentProject = "" } = options;
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
- closeAssistantReply({ ui, state: turnState });
54
- const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
55
- engine.setPendingAssistantRecallHints?.(assistantRecallHints);
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
- assistantRecallHints: recordedAssistantRecallHints,
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
@@ -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) => {
@@ -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
- const step = this.getStep();
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 { visibleWidth } from "@earendil-works/pi-tui";
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
- for (const seg of this.segments) {
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
- function requestRender() {
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
- requestRender();
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
- requestRender();
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) {
@@ -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
+ }
@@ -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 one or two sentences: what changed, verification status, and what's next if anything.
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
- if (!enabled) return { ok: false, reason: "disabled" };
13
- return sendDesktopNotification({
14
- platform,
15
- spawnProcess,
16
- title: event?.title ?? defaultTurnTitle(event?.status),
17
- message: event?.message ?? defaultTurnMessage(event),
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 child = spawnProcess("powershell.exe", [
32
- "-NoProfile",
33
- "-ExecutionPolicy", "Bypass",
34
- "-WindowStyle", "Hidden",
35
- "-Command", script,
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
- "$n.Icon = [System.Drawing.SystemIcons]::Information",
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
- const toastXml = escapePowerShellDoubleQuotedString(buildToastXml({ title, message }));
68
- const balloonScript = buildWindowsBalloonScript({ title, message, timeoutMs });
69
- return [
70
- "try {",
71
- "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null",
72
- "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null",
73
- "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument",
74
- `$xml.LoadXml("${toastXml}")`,
75
- "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
76
- "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('PowerShell').Show($toast)",
77
- "} catch {",
78
- balloonScript,
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 buildToastXml({ title, message }) {
84
- return `<toast><visual><binding template="ToastGeneric"><text>${escapeXmlText(title)}</text><text>${escapeXmlText(message)}</text></binding></visual></toast>`;
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 escapeXmlText(text) {
88
- return String(text)
89
- .replaceAll("&", "&amp;")
90
- .replaceAll("<", "&lt;")
91
- .replaceAll(">", "&gt;");
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 escapePowerShellDoubleQuotedString(text) {
95
- return String(text).replace(/[`"$]/g, "`$&");
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(status) {
99
- return status === "error" ? "March turn failed" : "March is ready";
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?.sessionName ? `${event.sessionName} is ready for review` : "Your turn is ready for review";
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
+ }