march-cli 0.1.2 → 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.
@@ -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: false },
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,16 +3,20 @@ 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;
15
17
  this.turns = [];
18
+ this.pendingAssistantRecallHints = [];
19
+ this.pendingAssistantRecallHintsRendered = false;
16
20
  this.sessionName = "";
17
21
  this.toolDefs = [];
18
22
  this.namespace = namespace;
@@ -58,6 +62,9 @@ export class ContextEngine {
58
62
  const projectCtx = buildProjectContext(this.cwd);
59
63
  if (projectCtx) layers.push({ name: "project_context", text: projectCtx });
60
64
 
65
+ const centerMemory = buildCenterMemory(this.centerMemoryPath);
66
+ if (centerMemory) layers.push({ name: "center_memory", text: centerMemory });
67
+
61
68
  layers.push({ name: "recent_chat", text: this.#buildRecentChat() });
62
69
 
63
70
  return layers;
@@ -86,6 +93,30 @@ export class ContextEngine {
86
93
  return ids;
87
94
  }
88
95
 
96
+ setPendingAssistantRecallHints(hints = []) {
97
+ this.pendingAssistantRecallHints = uniqueHints(hints);
98
+ this.pendingAssistantRecallHintsRendered = false;
99
+ }
100
+
101
+ peekPendingAssistantRecallHints() {
102
+ return this.pendingAssistantRecallHints;
103
+ }
104
+
105
+ hasRenderedPendingAssistantRecallHints() {
106
+ return this.pendingAssistantRecallHintsRendered;
107
+ }
108
+
109
+ markPendingAssistantRecallHintsRendered() {
110
+ if (this.pendingAssistantRecallHints.length > 0) this.pendingAssistantRecallHintsRendered = true;
111
+ }
112
+
113
+ takePendingAssistantRecallHints() {
114
+ const hints = this.pendingAssistantRecallHints;
115
+ this.pendingAssistantRecallHints = [];
116
+ this.pendingAssistantRecallHintsRendered = false;
117
+ return hints;
118
+ }
119
+
89
120
  resolvePath(raw) {
90
121
  return resolve(this.cwd, raw);
91
122
  }
@@ -107,9 +138,15 @@ export class ContextEngine {
107
138
  restoreSession(data, _pool, { replace = false } = {}) {
108
139
  if (replace) {
109
140
  this.turns = [];
141
+ this.pendingAssistantRecallHints = [];
142
+ this.pendingAssistantRecallHintsRendered = false;
110
143
  this.sessionName = "";
111
144
  }
112
145
  if (data.turns) this.turns = data.turns;
146
+ if (Array.isArray(data.pendingAssistantRecallHints)) {
147
+ this.pendingAssistantRecallHints = uniqueHints(data.pendingAssistantRecallHints);
148
+ this.pendingAssistantRecallHintsRendered = false;
149
+ }
113
150
  if (typeof data.sessionName === "string") this.sessionName = data.sessionName;
114
151
  this.setRuntimeState(data);
115
152
  }
@@ -147,3 +184,14 @@ function appendCurrentUser(recentChat, userMessage) {
147
184
  const currentUser = String(userMessage ?? "").trimEnd();
148
185
  return `${recentChat}\n\n[current_user]\n${currentUser}`;
149
186
  }
187
+
188
+ function uniqueHints(hints = []) {
189
+ const seen = new Set();
190
+ const unique = [];
191
+ for (const hint of hints) {
192
+ if (!hint?.id || seen.has(hint.id)) continue;
193
+ seen.add(hint.id);
194
+ unique.push(hint);
195
+ }
196
+ return unique;
197
+ }
@@ -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,