march-cli 0.1.3 → 0.1.4
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 +41 -10
- package/src/agent/turn/turn-runner.mjs +190 -18
- package/src/assets/march-icon.png +0 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "march-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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";
|
|
@@ -18,7 +18,7 @@ import { resolveRunnerSessionOptions } from "./session/session-options.mjs";
|
|
|
18
18
|
import { createSessionBinding } from "./session/session-binding.mjs";
|
|
19
19
|
import { maybeAutoNameSession } from "./session/session-auto-name.mjs";
|
|
20
20
|
import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
|
|
21
|
-
import { runRunnerTurn } from "./turn/turn-runner.mjs";
|
|
21
|
+
import { isModelStreamIdleTimeoutError, 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
24
|
|
|
@@ -27,7 +27,7 @@ export { installModelPayloadDumper } from "./model-payload-dumper.mjs";
|
|
|
27
27
|
export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
|
|
28
28
|
export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
|
|
29
29
|
|
|
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 }) {
|
|
30
|
+
export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, centerMemoryPath = null, memoryStore = null, memoryTools = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, modelStreamIdleTimeoutMs = 7000, modelStreamIdleMaxRetries = 5 }) {
|
|
31
31
|
if (!useRuntimeHost && extensionPaths.length > 0) {
|
|
32
32
|
throw new Error("--extension requires the default pi runtime host path");
|
|
33
33
|
}
|
|
@@ -47,7 +47,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
47
47
|
retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
|
|
48
48
|
});
|
|
49
49
|
const lspService = new LspService({ cwd });
|
|
50
|
-
const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
|
|
50
|
+
const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, centerMemoryPath, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
|
|
51
51
|
const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
|
|
52
52
|
const sessionBinding = createSessionBinding(null);
|
|
53
53
|
let currentModelCallKind = "model";
|
|
@@ -55,6 +55,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
55
55
|
let currentTurnContextMode = "rebuild";
|
|
56
56
|
let nextTurnContextMode = "rebuild";
|
|
57
57
|
let pendingMidTurnRecallHints = [];
|
|
58
|
+
let lastNotificationResult = null;
|
|
58
59
|
let runtimeHost = null;
|
|
59
60
|
let lifecycleAdapter = null;
|
|
60
61
|
let _currentFastEntry = null;
|
|
@@ -109,9 +110,10 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
109
110
|
currentTurnContextMode = contextMode;
|
|
110
111
|
nextTurnContextMode = "rebuild";
|
|
111
112
|
pendingMidTurnRecallHints = [];
|
|
113
|
+
const turnStartedAt = Date.now();
|
|
112
114
|
try {
|
|
113
115
|
const result = await runRunnerTurn({
|
|
114
|
-
prompt, userMessage, options: { userRecallHints, currentProject },
|
|
116
|
+
prompt, userMessage, options: { userRecallHints, currentProject, modelStreamIdleTimeoutMs, modelStreamIdleMaxRetries },
|
|
115
117
|
sessionBinding, engine, ui, projectMarchDir, memoryStore,
|
|
116
118
|
setModelCallKind: (kind) => { currentModelCallKind = kind; },
|
|
117
119
|
onMidTurnRecallHints: (hints) => { pendingMidTurnRecallHints.push(...hints); },
|
|
@@ -119,17 +121,22 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
|
|
|
119
121
|
autoNameSession,
|
|
120
122
|
contextMode,
|
|
121
123
|
});
|
|
122
|
-
await notifyTurnEndBestEffort(turnNotifier, {
|
|
124
|
+
lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
|
|
123
125
|
status: "success",
|
|
124
126
|
sessionName: engine.sessionName,
|
|
125
127
|
draft: result?.draft ?? "",
|
|
128
|
+
durationMs: Date.now() - turnStartedAt,
|
|
126
129
|
});
|
|
127
130
|
return result;
|
|
128
131
|
} catch (err) {
|
|
129
|
-
|
|
132
|
+
if (isModelStreamIdleTimeoutError(err)) {
|
|
133
|
+
nextTurnContextMode = "continueExistingPiTranscript";
|
|
134
|
+
}
|
|
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
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
|
|
2
2
|
import { closeAssistantReply, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
|
|
3
3
|
|
|
4
|
+
export function isModelStreamIdleTimeoutError(err) {
|
|
5
|
+
return err?.code === "MODEL_STREAM_IDLE_TIMEOUT";
|
|
6
|
+
}
|
|
7
|
+
|
|
4
8
|
export async function runRunnerTurn({
|
|
5
9
|
prompt,
|
|
6
10
|
userMessage,
|
|
@@ -16,13 +20,24 @@ export async function runRunnerTurn({
|
|
|
16
20
|
autoNameSession,
|
|
17
21
|
contextMode = "rebuild",
|
|
18
22
|
}) {
|
|
19
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
userRecallHints = [],
|
|
25
|
+
currentProject = "",
|
|
26
|
+
modelStreamIdleTimeoutMs = 7000,
|
|
27
|
+
modelStreamIdleMaxRetries = 5,
|
|
28
|
+
} = options;
|
|
20
29
|
const activeSession = sessionBinding.get();
|
|
21
30
|
const turnState = createTurnEventState();
|
|
22
31
|
const midTurnRecallHints = [];
|
|
32
|
+
const modelIdleWatchdog = createModelStreamIdleWatchdog({
|
|
33
|
+
session: activeSession,
|
|
34
|
+
ui,
|
|
35
|
+
timeoutMs: modelStreamIdleTimeoutMs,
|
|
36
|
+
});
|
|
23
37
|
ui.turnStart();
|
|
24
38
|
|
|
25
39
|
const unsubscribe = activeSession.subscribe((event) => {
|
|
40
|
+
modelIdleWatchdog.handleEvent(event);
|
|
26
41
|
handleRunnerSessionEvent(event, { ui, engine, state: turnState });
|
|
27
42
|
if (event.type === "tool_execution_start") {
|
|
28
43
|
const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
|
|
@@ -41,29 +56,48 @@ export async function runRunnerTurn({
|
|
|
41
56
|
});
|
|
42
57
|
setModelCallKind("user");
|
|
43
58
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
|
|
47
|
-
attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
|
|
48
|
-
|
|
59
|
+
await promptWithModelIdleRetry({
|
|
60
|
+
session: activeSession,
|
|
61
|
+
prompt: contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
|
|
62
|
+
promptOptions: attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
|
|
63
|
+
resetBeforeAttempt: contextMode === "rebuild",
|
|
64
|
+
maxRetries: modelStreamIdleMaxRetries,
|
|
65
|
+
watchdog: modelIdleWatchdog,
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (!isModelStreamIdleTimeoutError(err)) throw err;
|
|
69
|
+
finalizeTurn({
|
|
70
|
+
prompt,
|
|
71
|
+
userMessage,
|
|
72
|
+
userRecallHints,
|
|
73
|
+
currentProject,
|
|
74
|
+
memoryStore,
|
|
75
|
+
engine,
|
|
76
|
+
ui,
|
|
77
|
+
turnState,
|
|
78
|
+
midTurnRecallHints,
|
|
79
|
+
syncCurrentPiSidecar,
|
|
80
|
+
autoNameSession,
|
|
81
|
+
});
|
|
82
|
+
throw err;
|
|
49
83
|
} finally {
|
|
84
|
+
modelIdleWatchdog.stop();
|
|
50
85
|
setModelCallKind("model");
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
|
|
57
|
-
|
|
58
|
-
engine.recordTurn({
|
|
59
|
-
userMessage: userMessage ?? prompt.slice(0, 300),
|
|
60
|
-
assistantMessage: turnState.draft,
|
|
88
|
+
finalizeTurn({
|
|
89
|
+
prompt,
|
|
90
|
+
userMessage,
|
|
61
91
|
userRecallHints,
|
|
62
|
-
|
|
92
|
+
currentProject,
|
|
93
|
+
memoryStore,
|
|
94
|
+
engine,
|
|
95
|
+
ui,
|
|
96
|
+
turnState,
|
|
97
|
+
midTurnRecallHints,
|
|
98
|
+
syncCurrentPiSidecar,
|
|
99
|
+
autoNameSession,
|
|
63
100
|
});
|
|
64
|
-
|
|
65
|
-
autoNameSession?.();
|
|
66
|
-
syncCurrentPiSidecar();
|
|
67
101
|
return { draft: turnState.draft };
|
|
68
102
|
} finally {
|
|
69
103
|
ui.turnEnd();
|
|
@@ -71,6 +105,144 @@ export async function runRunnerTurn({
|
|
|
71
105
|
}
|
|
72
106
|
}
|
|
73
107
|
|
|
108
|
+
async function promptWithModelIdleRetry({ session, prompt, promptOptions, resetBeforeAttempt, maxRetries, watchdog }) {
|
|
109
|
+
let attempt = 0;
|
|
110
|
+
while (true) {
|
|
111
|
+
attempt += 1;
|
|
112
|
+
if (resetBeforeAttempt) resetPiMessageHistory(session);
|
|
113
|
+
const messageHistorySnapshot = clonePiMessageHistory(session);
|
|
114
|
+
watchdog.startAttempt({ attempt, maxAttempts: maxRetries + 1 });
|
|
115
|
+
let idleTimedOut = false;
|
|
116
|
+
try {
|
|
117
|
+
await session.prompt(prompt, promptOptions);
|
|
118
|
+
idleTimedOut = watchdog.consumeTimedOut();
|
|
119
|
+
if (!idleTimedOut) {
|
|
120
|
+
if (attempt > 1) watchdog.reportRecovered({ attempt: attempt - 1 });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
idleTimedOut = watchdog.consumeTimedOut();
|
|
125
|
+
if (!idleTimedOut) throw err;
|
|
126
|
+
} finally {
|
|
127
|
+
watchdog.stop();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (attempt > maxRetries) {
|
|
131
|
+
const err = createModelStreamIdleTimeoutError(watchdog.timeoutMs);
|
|
132
|
+
watchdog.reportExhausted({ attempt, error: err });
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
restorePiMessageHistory(session, messageHistorySnapshot);
|
|
136
|
+
watchdog.reportRetry({ attempt, maxAttempts: maxRetries + 1 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession }) {
|
|
141
|
+
closeAssistantReply({ ui, state: turnState });
|
|
142
|
+
const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
|
|
143
|
+
engine.setPendingAssistantRecallHints?.(assistantRecallHints);
|
|
144
|
+
const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
|
|
145
|
+
|
|
146
|
+
engine.recordTurn({
|
|
147
|
+
userMessage: userMessage ?? prompt.slice(0, 300),
|
|
148
|
+
assistantMessage: turnState.draft,
|
|
149
|
+
userRecallHints,
|
|
150
|
+
assistantRecallHints: recordedAssistantRecallHints,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
autoNameSession?.();
|
|
154
|
+
syncCurrentPiSidecar();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createModelStreamIdleTimeoutError(timeoutMs) {
|
|
158
|
+
const err = new Error(`Model stream idle for ${timeoutMs}ms`);
|
|
159
|
+
err.code = "MODEL_STREAM_IDLE_TIMEOUT";
|
|
160
|
+
return err;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createModelStreamIdleWatchdog({ session, ui, timeoutMs }) {
|
|
164
|
+
let timer = null;
|
|
165
|
+
let active = false;
|
|
166
|
+
let inTool = false;
|
|
167
|
+
let timedOut = false;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
timeoutMs,
|
|
171
|
+
startAttempt() {
|
|
172
|
+
timedOut = false;
|
|
173
|
+
inTool = false;
|
|
174
|
+
active = true;
|
|
175
|
+
arm();
|
|
176
|
+
},
|
|
177
|
+
handleEvent(event) {
|
|
178
|
+
if (!active || timedOut) return;
|
|
179
|
+
if (event.type === "tool_execution_start") {
|
|
180
|
+
inTool = true;
|
|
181
|
+
clear();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (event.type === "tool_execution_end") {
|
|
185
|
+
inTool = false;
|
|
186
|
+
arm();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (event.type === "message_update") arm();
|
|
190
|
+
},
|
|
191
|
+
stop() {
|
|
192
|
+
active = false;
|
|
193
|
+
clear();
|
|
194
|
+
},
|
|
195
|
+
consumeTimedOut() {
|
|
196
|
+
const value = timedOut;
|
|
197
|
+
timedOut = false;
|
|
198
|
+
return value;
|
|
199
|
+
},
|
|
200
|
+
reportRetry({ attempt, maxAttempts }) {
|
|
201
|
+
ui.retryStart?.({
|
|
202
|
+
attempt,
|
|
203
|
+
maxAttempts,
|
|
204
|
+
delayMs: 0,
|
|
205
|
+
errorMessage: `Model stream idle for ${timeoutMs}ms; retrying request`,
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
reportRecovered({ attempt }) {
|
|
209
|
+
ui.retryEnd?.({ success: true, attempt });
|
|
210
|
+
},
|
|
211
|
+
reportExhausted({ attempt, error }) {
|
|
212
|
+
ui.retryEnd?.({ success: false, attempt, finalError: error.message });
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function arm() {
|
|
217
|
+
clear();
|
|
218
|
+
if (!active || inTool || timeoutMs <= 0) return;
|
|
219
|
+
timer = setTimeout(() => {
|
|
220
|
+
timedOut = true;
|
|
221
|
+
active = false;
|
|
222
|
+
clear();
|
|
223
|
+
session.abortRetry?.();
|
|
224
|
+
session.abort();
|
|
225
|
+
}, timeoutMs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function clear() {
|
|
229
|
+
if (!timer) return;
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
timer = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function clonePiMessageHistory(session) {
|
|
236
|
+
const messages = session?.agent?.state?.messages;
|
|
237
|
+
if (!Array.isArray(messages)) return null;
|
|
238
|
+
return JSON.parse(JSON.stringify(messages));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function restorePiMessageHistory(session, messages) {
|
|
242
|
+
if (!Array.isArray(session?.agent?.state?.messages) || !Array.isArray(messages)) return;
|
|
243
|
+
session.agent.state.messages = JSON.parse(JSON.stringify(messages));
|
|
244
|
+
}
|
|
245
|
+
|
|
74
246
|
function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
|
|
75
247
|
if (!memoryStore) return [];
|
|
76
248
|
const text = assistantRecallDeltaText(turnState);
|
|
Binary file
|
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
|
}
|