march-cli 0.1.41 → 0.1.45

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/agent/code-search/cache.mjs +3 -2
  3. package/src/agent/code-search/engine.mjs +2 -0
  4. package/src/agent/code-search/retrieval/resilient-vectorizer.mjs +59 -0
  5. package/src/agent/code-search/tool.mjs +11 -5
  6. package/src/agent/runner.mjs +2 -1
  7. package/src/agent/runtime/remote-ui-client.mjs +1 -1
  8. package/src/agent/runtime/ui-event-bridge.mjs +2 -2
  9. package/src/agent/turn/turn-events.mjs +6 -0
  10. package/src/agent/turn/turn-runner.mjs +110 -23
  11. package/src/cli/fallback-ui.mjs +2 -2
  12. package/src/cli/repl-loop.mjs +7 -7
  13. package/src/cli/startup/app-runtime.mjs +5 -2
  14. package/src/cli/tui/output/timeline-block-restore.mjs +1 -1
  15. package/src/cli/tui/recall-rendering.mjs +14 -7
  16. package/src/cli/turn/turn-input-preparer.mjs +6 -4
  17. package/src/cli/ui.mjs +2 -2
  18. package/src/cli/workspace/tui-timeline-projection.mjs +1 -1
  19. package/src/context/engine.mjs +2 -2
  20. package/src/context/system-core/base.md +1 -1
  21. package/src/memory/markdown/markdown-format.mjs +0 -17
  22. package/src/memory/markdown/markdown-recall.mjs +11 -19
  23. package/src/memory/markdown/semantic-preload.mjs +17 -0
  24. package/src/memory/markdown/semantic-recall.mjs +165 -0
  25. package/src/memory/markdown/sqlite-index.mjs +1 -13
  26. package/src/memory/markdown-store.mjs +24 -52
  27. package/src/web-ui/dist/assets/{index-DrlJis_D.js → index-CBYbNVgs.js} +1 -1
  28. package/src/web-ui/dist/assets/{index-BQtl1uQs.css → index-CcbYCcWs.css} +1 -1
  29. package/src/web-ui/dist/index.html +2 -2
  30. package/src/web-ui/runtime-host.mjs +5 -2
  31. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +24 -0
  32. package/src/web-ui/src/model.ts +18 -0
  33. package/src/web-ui/src/runtime/client.ts +2 -1
  34. package/src/web-ui/src/runtime/runtimeTimeline.ts +5 -0
  35. package/src/web-ui/src/styles/shell.css +6 -0
  36. package/src/web-ui/src/timelineAdapter.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.41",
3
+ "version": "0.1.45",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { chunkFile } from "./chunker.mjs";
4
4
  import { Bm25Index } from "./retrieval/bm25.mjs";
5
+ import { describeVectorizer } from "./retrieval/resilient-vectorizer.mjs";
5
6
  import { LocalVectorIndex, defaultVectorizer } from "./retrieval/vector.mjs";
6
7
 
7
8
  const DEFAULT_MAX_FILE_ENTRIES = 8_000;
@@ -53,7 +54,7 @@ export class CodeSearchIndexCache {
53
54
  if (cachedIndex) {
54
55
  this.indices.delete(indexSignature);
55
56
  this.indices.set(indexSignature, cachedIndex);
56
- return { chunks, index: cachedIndex, reusedFiles, indexedFiles, reusedIndex: true, vectorizer: this.vectorizer.id };
57
+ return { chunks, index: cachedIndex, reusedFiles, indexedFiles, reusedIndex: true, ...describeVectorizer(this.vectorizer) };
57
58
  }
58
59
 
59
60
  const index = {
@@ -62,7 +63,7 @@ export class CodeSearchIndexCache {
62
63
  };
63
64
  this.indices.set(indexSignature, index);
64
65
  this.pruneIndexCache();
65
- return { chunks, index, reusedFiles, indexedFiles, reusedIndex: false, vectorizer: this.vectorizer.id };
66
+ return { chunks, index, reusedFiles, indexedFiles, reusedIndex: false, ...describeVectorizer(this.vectorizer) };
66
67
  }
67
68
 
68
69
  clear() {
@@ -79,6 +79,8 @@ function formatStats(files, built, mode) {
79
79
  indexed_files: built.indexedFiles,
80
80
  reused_index: built.reusedIndex,
81
81
  vectorizer: built.vectorizer,
82
+ vectorizer_status: built.vectorizer_status,
83
+ vectorizer_warning: built.vectorizer_warning,
82
84
  };
83
85
  }
84
86
 
@@ -0,0 +1,59 @@
1
+ import { HashingVectorizer } from "./vector.mjs";
2
+
3
+ export class ResilientVectorizer {
4
+ constructor({ primary, fallback = new HashingVectorizer(), label = "embedding" } = {}) {
5
+ if (!primary) throw new Error("ResilientVectorizer requires primary");
6
+ this.primary = primary;
7
+ this.fallback = fallback;
8
+ this.label = label;
9
+ this.id = `resilient:${primary.id}->${fallback.id}`;
10
+ this.active = primary;
11
+ this.status = "primary";
12
+ this.warning = null;
13
+ }
14
+
15
+ get dimensions() {
16
+ return this.active.dimensions;
17
+ }
18
+
19
+ get activeId() {
20
+ return this.active.id;
21
+ }
22
+
23
+ async load() {
24
+ if (this.status === "fallback") return false;
25
+ try {
26
+ if (typeof this.primary.load === "function") await this.primary.load();
27
+ else await this.primary.encode([`${this.label} warmup`]);
28
+ return true;
29
+ } catch (err) {
30
+ this.#activateFallback(err);
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async encode(texts) {
36
+ if (this.status === "fallback") return this.fallback.encode(texts);
37
+ try {
38
+ return await this.primary.encode(texts);
39
+ } catch (err) {
40
+ this.#activateFallback(err);
41
+ return this.fallback.encode(texts);
42
+ }
43
+ }
44
+
45
+ #activateFallback(err) {
46
+ this.active = this.fallback;
47
+ this.status = "fallback";
48
+ const message = err?.message ?? String(err);
49
+ this.warning = `${this.label} Model2Vec unavailable; using local hashing fallback: ${message}`;
50
+ }
51
+ }
52
+
53
+ export function describeVectorizer(vectorizer) {
54
+ return {
55
+ vectorizer: vectorizer?.activeId ?? vectorizer?.id ?? "unknown",
56
+ vectorizer_status: vectorizer?.status ?? "primary",
57
+ vectorizer_warning: vectorizer?.warning ?? null,
58
+ };
59
+ }
@@ -5,6 +5,7 @@ import { toolText } from "../tool-result.mjs";
5
5
  import { CodeSearchIndexCache } from "./cache.mjs";
6
6
  import { searchCode } from "./engine.mjs";
7
7
  import { Model2VecVectorizer, POTION_CODE_MODEL_ID } from "./retrieval/model2vec.mjs";
8
+ import { ResilientVectorizer } from "./retrieval/resilient-vectorizer.mjs";
8
9
 
9
10
  const persistentCaches = new Map();
10
11
 
@@ -23,7 +24,7 @@ export function createCodeSearchTool({ engine, stateRoot = null }) {
23
24
  Type.Literal("symbol"),
24
25
  Type.Literal("lexical"),
25
26
  Type.Literal("semantic"),
26
- ], { description: "Search mode. auto uses BM25 + Model2Vec retrieval with RRF fusion." })),
27
+ ], { description: "Search mode. auto uses BM25 + Model2Vec retrieval with RRF fusion; falls back to local hashing if the model is unavailable." })),
27
28
  include_tests: Type.Optional(Type.Boolean({ description: "Include test/spec paths without penalty; default false" })),
28
29
  related_to: Type.Optional(Type.Object({
29
30
  file_path: Type.String({ description: "Workspace-relative file path containing the known code" }),
@@ -53,7 +54,10 @@ function persistentCacheFor(stateRoot) {
53
54
  if (!cache) {
54
55
  cache = new CodeSearchIndexCache({
55
56
  storagePath,
56
- vectorizer: new Model2VecVectorizer({ modelDir }),
57
+ vectorizer: new ResilientVectorizer({
58
+ primary: new Model2VecVectorizer({ modelDir }),
59
+ label: "code_search",
60
+ }),
57
61
  });
58
62
  persistentCaches.set(cacheKey, cache);
59
63
  }
@@ -61,13 +65,15 @@ function persistentCacheFor(stateRoot) {
61
65
  }
62
66
 
63
67
  function formatSearchOutput({ results, stats }) {
64
- const header = `--- code_search (${results.length} results, ${stats.files} files, ${stats.chunks} chunks, ${stats.mode}) ---`;
65
- if (results.length === 0) return `${header}\nNo matching code snippets found.`;
68
+ const mode = stats.vectorizer_status === "fallback" ? `${stats.mode}-fallback` : stats.mode;
69
+ const header = `--- code_search (${results.length} results, ${stats.files} files, ${stats.chunks} chunks, ${mode}) ---`;
70
+ const warning = stats.vectorizer_warning ? `\nwarning: ${stats.vectorizer_warning}` : "";
71
+ if (results.length === 0) return `${header}${warning}\nNo matching code snippets found.`;
66
72
  const body = results.map((result, index) => [
67
73
  `${index + 1}. ${result.file_path}:${result.start_line}-${result.end_line} score=${result.score} kind=${result.kind}${result.symbols.length ? ` symbols=${result.symbols.join(",")}` : ""}`,
68
74
  fenceSnippet(result.snippet),
69
75
  ].join("\n")).join("\n\n");
70
- return `${header}\n${body}`;
76
+ return `${header}${warning}\n${body}`;
71
77
  }
72
78
 
73
79
  function fenceSnippet(snippet) {
@@ -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 { MODEL_STREAM_IDLE_TIMEOUT_CODE, runRunnerTurn } from "./turn/turn-runner.mjs";
22
22
  import { beginLoggedTurn } from "./turn/turn-logging.mjs";
23
23
  import { appendFastVariants, createFastModelEntry, fromFastEntryModel, isFastProvider } from "./runner/fast-model.mjs";
24
24
  import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
@@ -142,6 +142,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
142
142
  turnLog.endSuccess(result);
143
143
  return result;
144
144
  } catch (err) {
145
+ if (err?.code === MODEL_STREAM_IDLE_TIMEOUT_CODE) nextTurnContextMode = "continueExistingPiTranscript";
145
146
  notifyTurnEndDetached(turnNotifier, {
146
147
  status: "error",
147
148
  sessionName: engine.sessionName,
@@ -13,7 +13,7 @@ export function createRemoteRuntimeUiClient(peer) {
13
13
  retryEnd: (event) => peer.notify("uiEvent", { type: "retry_end", ...event }),
14
14
  status: (text) => peer.notify("uiEvent", { type: "status", text }),
15
15
  debugLines: (lines) => peer.notify("uiEvent", { type: "debug_lines", lines }),
16
- recall: ({ source, hints }) => peer.notify("uiEvent", { type: "recall", source, hints }),
16
+ recall: ({ source, hints, report }) => peer.notify("uiEvent", { type: "recall", source, hints, report }),
17
17
  editDiff: (path, diffLines) => peer.notify("uiEvent", { type: "edit_diff", path, diffLines }),
18
18
  };
19
19
  }
@@ -50,7 +50,7 @@ export function createRuntimeUiClient(eventBus) {
50
50
  retryEnd: (event) => eventBus.emit({ type: "retry_end", ...event }),
51
51
  status: (text) => eventBus.emit({ type: "status", text }),
52
52
  debugLines: (lines) => eventBus.emit({ type: "debug_lines", lines }),
53
- recall: ({ source, hints }) => eventBus.emit({ type: "recall", source, hints }),
53
+ recall: ({ source, hints, report }) => eventBus.emit({ type: "recall", source, hints, report }),
54
54
  providerQuotaSnapshot: (snapshot) => eventBus.emit({ type: "provider_quota_snapshot", snapshot }),
55
55
  editDiff: (path, diffLines) => eventBus.emit({ type: "edit_diff", path, diffLines }),
56
56
  };
@@ -71,7 +71,7 @@ export function dispatchRuntimeUiEvent(ui, event) {
71
71
  case "retry_end": return ui.retryEnd?.(pickRetryEnd(event));
72
72
  case "status": return ui.status?.(event.text);
73
73
  case "debug_lines": return writeDebugLines(ui, event.lines);
74
- case "recall": return ui.recall?.({ source: event.source, hints: event.hints });
74
+ case "recall": return ui.recall?.({ hints: event.hints, report: event.report });
75
75
  case "provider_quota_snapshot": return ui.providerQuotaSnapshot?.(event.snapshot);
76
76
  case "edit_diff": return ui.editDiff?.(event.path, event.diffLines);
77
77
  default: return undefined;
@@ -12,6 +12,8 @@ export function createTurnEventState() {
12
12
  assistantContextParts: [],
13
13
  activeToolContextPart: null,
14
14
  toolCalls: [],
15
+ lastAssistantStopReason: null,
16
+ lastAssistantErrorMessage: null,
15
17
  };
16
18
  }
17
19
 
@@ -19,6 +21,10 @@ export function handleRunnerSessionEvent(event, { ui, engine, state }) {
19
21
  if (event.type === "message_update" && event.assistantMessageEvent) {
20
22
  handleAssistantMessageEvent(event.assistantMessageEvent, { ui, state });
21
23
  }
24
+ if (event.type === "message_end" && event.message?.role === "assistant") {
25
+ state.lastAssistantStopReason = event.message.stopReason ?? null;
26
+ state.lastAssistantErrorMessage = event.message.errorMessage ?? null;
27
+ }
22
28
  if (event.type === "tool_execution_start") {
23
29
  closeAssistantReply({ ui, state });
24
30
  appendToolStartContext(state, event.toolName, event.args);
@@ -2,6 +2,8 @@ import { formatRecallHints } from "../../memory/markdown-store.mjs";
2
2
  import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
3
3
  import { closeAssistantReply, compactAssistantContext, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
4
4
 
5
+ export const MODEL_STREAM_IDLE_TIMEOUT_CODE = "MODEL_STREAM_IDLE_TIMEOUT";
6
+
5
7
  export async function runRunnerTurn({
6
8
  prompt,
7
9
  userMessage,
@@ -21,30 +23,42 @@ export async function runRunnerTurn({
21
23
  }) {
22
24
  const {
23
25
  userRecallHints = [],
24
- currentProject = "",
25
26
  } = options;
26
27
  const activeSession = sessionBinding.get();
27
28
  const turnState = createTurnEventState();
28
29
  const midTurnRecallHints = [];
30
+ const midTurnRecallTasks = [];
31
+ const idleWatchdog = createModelStreamIdleWatchdog({ session: activeSession, logger, setPhase });
29
32
  ui.turnStart();
30
33
  setPhase?.("subscribed");
31
34
  logger?.event("turn.ui.start");
32
35
 
33
36
  const unsubscribe = activeSession.subscribe((event) => {
34
37
  logSessionEvent(logger, event);
35
- if (event.type === "tool_execution_start") setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
36
- if (event.type === "tool_execution_end") setPhase?.("model_streaming");
37
- if (event.type === "auto_retry_start") setPhase?.("retry_wait");
38
- if (event.type === "auto_retry_end") setPhase?.("model_streaming");
39
- if (event.type === "message_update") setPhase?.("model_streaming");
38
+ if (event.type === "tool_execution_start") {
39
+ idleWatchdog.pause();
40
+ setPhase?.(`tool_running:${event.toolName ?? "unknown"}`);
41
+ }
42
+ if (event.type === "tool_execution_end") {
43
+ setPhase?.("model_streaming");
44
+ idleWatchdog.arm("tool_execution_end");
45
+ }
46
+ if (event.type === "auto_retry_start") {
47
+ idleWatchdog.pause();
48
+ setPhase?.("retry_wait");
49
+ }
50
+ if (event.type === "auto_retry_end") {
51
+ setPhase?.("model_streaming");
52
+ idleWatchdog.arm("auto_retry_end");
53
+ }
54
+ if (event.type === "message_update") {
55
+ setPhase?.("model_streaming");
56
+ idleWatchdog.arm("message_update");
57
+ }
40
58
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
41
59
  if (event.type === "tool_execution_start") {
42
- const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
43
- if (hints.length > 0) {
44
- midTurnRecallHints.push(...hints);
45
- queueMidTurnRecallHints(activeSession, hints, logger);
46
- ui.recall?.({ source: "assistant", hints });
47
- }
60
+ const task = flushMidTurnAssistantRecall({ memoryStore, engine, turnState, activeSession, ui, logger, midTurnRecallHints });
61
+ midTurnRecallTasks.push(task);
48
62
  }
49
63
  });
50
64
 
@@ -59,21 +73,24 @@ export async function runRunnerTurn({
59
73
  logger?.event("model.prompt.start", { contextMode });
60
74
  try {
61
75
  if (contextMode === "rebuild") resetPiMessageHistory(activeSession);
62
- await activeSession.prompt(
76
+ idleWatchdog.arm("model_request");
77
+ await idleWatchdog.watch(activeSession.prompt(
63
78
  contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
64
79
  attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
65
- );
80
+ ));
81
+ throwIfAssistantEndedWithError(turnState);
66
82
  } finally {
83
+ idleWatchdog.clear();
67
84
  setModelCallKind("model");
68
85
  logger?.event("model.prompt.end");
69
86
  }
70
87
 
71
88
  setPhase?.("finalizing");
72
- finalizeTurn({
89
+ await Promise.allSettled(midTurnRecallTasks);
90
+ await finalizeTurn({
73
91
  prompt,
74
92
  userMessage,
75
93
  userRecallHints,
76
- currentProject,
77
94
  memoryStore,
78
95
  engine,
79
96
  ui,
@@ -86,19 +103,78 @@ export async function runRunnerTurn({
86
103
  return { draft: turnState.draft };
87
104
  } finally {
88
105
  logger?.event("turn.ui.end");
106
+ idleWatchdog.clear();
89
107
  ui.turnEnd();
90
108
  unsubscribe();
91
109
  }
92
110
  }
93
111
 
112
+ function createModelStreamIdleWatchdog({ session, logger, setPhase }) {
113
+ const timeoutMs = getModelStreamIdleTimeoutMs();
114
+ let timer = null;
115
+ let timedOut = false;
116
+ let rejectIdle = null;
117
+ const idlePromise = new Promise((_, reject) => {
118
+ rejectIdle = reject;
119
+ });
120
+
121
+ return {
122
+ arm(reason) {
123
+ if (timeoutMs <= 0 || timedOut) return;
124
+ clearTimer();
125
+ timer = setTimeout(() => {
126
+ timedOut = true;
127
+ setPhase?.("model_idle_timeout");
128
+ logger?.event("model.stream.idle_timeout", { timeoutMs, reason });
129
+ try { session.abortRetry?.(); } catch {}
130
+ try { session.abort?.(); } catch {}
131
+ rejectIdle(createModelStreamIdleTimeoutError(timeoutMs, reason));
132
+ }, timeoutMs);
133
+ },
134
+ pause: clearTimer,
135
+ clear: clearTimer,
136
+ async watch(promise) {
137
+ const guarded = Promise.resolve(promise);
138
+ guarded.catch(() => {});
139
+ return await Promise.race([guarded, idlePromise]);
140
+ },
141
+ };
142
+
143
+ function clearTimer() {
144
+ if (!timer) return;
145
+ clearTimeout(timer);
146
+ timer = null;
147
+ }
148
+ }
149
+
150
+ function createModelStreamIdleTimeoutError(timeoutMs, reason) {
151
+ const error = new Error(`Model stream idle timeout after ${timeoutMs}ms (${reason}); aborted the current turn`);
152
+ error.code = MODEL_STREAM_IDLE_TIMEOUT_CODE;
153
+ return error;
154
+ }
155
+
156
+ function getModelStreamIdleTimeoutMs() {
157
+ const raw = process.env.MARCH_MODEL_STREAM_IDLE_TIMEOUT_MS;
158
+ if (raw === "0" || raw === "false" || raw === "no") return 0;
159
+ const parsed = raw ? Number.parseInt(raw, 10) : 18000;
160
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 18000;
161
+ }
162
+
163
+ function throwIfAssistantEndedWithError(turnState) {
164
+ if (turnState.lastAssistantStopReason !== "error") return;
165
+ const error = new Error(turnState.lastAssistantErrorMessage || "Model provider returned an error");
166
+ error.code = "MODEL_PROVIDER_ERROR";
167
+ throw error;
168
+ }
169
+
94
170
  function queueMidTurnRecallHints(session, hints, logger) {
95
- const content = formatRecallHints("assistant", hints);
171
+ const content = formatRecallHints(hints);
96
172
  if (!content) return;
97
173
  const injected = session.sendCustomMessage?.({
98
174
  customType: "march.recall",
99
175
  content,
100
176
  display: false,
101
- details: { source: "assistant" },
177
+ details: { type: "recall" },
102
178
  }, { deliverAs: "steer" });
103
179
  void injected?.catch?.((err) => {
104
180
  logger?.debug("memory.mid_turn_recall.inject_failed", { errorMessage: err?.message ?? String(err) });
@@ -129,9 +205,9 @@ function logSessionEvent(logger, event) {
129
205
  });
130
206
  }
131
207
 
132
- function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentMarchSessionState, autoNameSession, recordHistory }) {
208
+ async function finalizeTurn({ prompt, userMessage, userRecallHints, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentMarchSessionState, autoNameSession, recordHistory }) {
133
209
  closeAssistantReply({ ui, state: turnState });
134
- const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
210
+ const assistantRecallHints = await flushAssistantRecall({ memoryStore, engine, turnState });
135
211
  engine.setPendingAssistantRecallHints?.(assistantRecallHints);
136
212
  const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
137
213
 
@@ -148,17 +224,28 @@ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, me
148
224
  syncCurrentMarchSessionState();
149
225
  }
150
226
 
151
- function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
227
+ async function flushAssistantRecall({ memoryStore, engine, turnState }) {
152
228
  if (!memoryStore) return [];
153
229
  const text = assistantRecallDeltaText(turnState);
154
230
  advanceAssistantRecallCursor(turnState);
155
231
  if (!text.trim()) return [];
156
- return memoryStore.recallForAssistant(text, {
157
- currentProject,
232
+ return await memoryStore.recallForAssistant(text, {
158
233
  excludedIds: engine.getRecentRecallMemoryIds?.() ?? [],
159
234
  });
160
235
  }
161
236
 
237
+ async function flushMidTurnAssistantRecall({ memoryStore, engine, turnState, activeSession, ui, logger, midTurnRecallHints }) {
238
+ try {
239
+ const hints = await flushAssistantRecall({ memoryStore, engine, turnState });
240
+ if (hints.length === 0) return;
241
+ midTurnRecallHints.push(...hints);
242
+ queueMidTurnRecallHints(activeSession, hints, logger);
243
+ ui.recall?.({ hints });
244
+ } catch (err) {
245
+ logger?.debug("memory.mid_turn_recall.failed", { errorMessage: err?.message ?? String(err) });
246
+ }
247
+ }
248
+
162
249
  function assistantRecallDeltaText(turnState) {
163
250
  const cursor = turnState.recallCursor ?? { draftLength: 0, thinkingLength: 0 };
164
251
  const thinking = assistantThinkingText(turnState);
@@ -109,9 +109,9 @@ export function createPlainUI() {
109
109
  },
110
110
  textDelta: writeText,
111
111
  status: (text) => { ensureNewline(); stdout.write(`${brightBlack(`● ${text}`)}\n`); },
112
- recall: ({ hints }) => {
112
+ recall: ({ hints, report }) => {
113
113
  ensureNewline();
114
- for (const line of formatRecallLines(hints)) stdout.write(`${brightBlack(line)}\n`);
114
+ for (const line of formatRecallLines(hints, report)) stdout.write(`${brightBlack(line)}\n`);
115
115
  },
116
116
  clearOutput: () => {},
117
117
  restoreTranscript: () => {},
@@ -16,10 +16,10 @@ export async function runSingleShotPrompt({
16
16
  }) {
17
17
  memoryStore.beginTurn();
18
18
  try {
19
- const turnInput = prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
19
+ const turnInput = await prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
20
20
  ui.writeln(turnInput.displayMessage);
21
- ui.recall?.({ source: "user", hints: turnInput.userRecallHints });
22
- if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ source: "assistant", hints: turnInput.carryoverRecallHints });
21
+ ui.recall?.({ hints: turnInput.userRecallHints, report: turnInput.userRecallReport });
22
+ if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ hints: turnInput.carryoverRecallHints });
23
23
  refreshStatusBar.startWorking?.();
24
24
  const result = await runner.runTurn(turnInput.fullPrompt, turnInput.userMessage, turnInput.runOptions);
25
25
  renderPendingAssistantRecallPreview({ runner, ui });
@@ -185,10 +185,10 @@ function startReplTurn({ runtime, prompt, ui, refreshStatusBar, setTurnRunning,
185
185
  async function runReplTurn({ prompt, runner, memoryStore, currentProject, ui, refreshStatusBar, setTurnRunning, modeState = null }) {
186
186
  memoryStore.beginTurn();
187
187
  try {
188
- const turnInput = prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
188
+ const turnInput = await prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState });
189
189
  ui.writeln(turnInput.displayMessage);
190
- ui.recall?.({ source: "user", hints: turnInput.userRecallHints });
191
- if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ source: "assistant", hints: turnInput.carryoverRecallHints });
190
+ ui.recall?.({ hints: turnInput.userRecallHints, report: turnInput.userRecallReport });
191
+ if (turnInput.shouldRenderCarryoverRecall) ui.recall?.({ hints: turnInput.carryoverRecallHints });
192
192
  setTurnRunning(true);
193
193
  refreshStatusBar.startWorking?.();
194
194
  const result = await runner.runTurn(turnInput.fullPrompt, turnInput.userMessage, turnInput.runOptions);
@@ -216,6 +216,6 @@ function renderPendingAssistantRecallPreview({ runner, ui }) {
216
216
  if (runner.engine.hasRenderedPendingAssistantRecallHints?.()) return;
217
217
  const hints = runner.engine.peekPendingAssistantRecallHints?.() ?? [];
218
218
  if (hints.length === 0) return;
219
- ui.recall?.({ source: "assistant", hints });
219
+ ui.recall?.({ hints });
220
220
  runner.engine.markPendingAssistantRecallHintsRendered?.();
221
221
  }
@@ -11,6 +11,7 @@ import { createMarchAuthStorage } from "../../auth/storage.mjs";
11
11
  import { createRuntimeRunner } from "./create-runtime-runner.mjs";
12
12
  import { createCliShellRuntime } from "../../shell/cli-runtime.mjs";
13
13
  import { MarkdownMemoryStore } from "../../memory/markdown-store.mjs";
14
+ import { preloadSemanticMemoryRecall } from "../../memory/markdown/semantic-preload.mjs";
14
15
  import { discoverProjectExtensionPaths } from "../../extensions/discovery.mjs";
15
16
  import { loadProjectLifecycleHookManifests } from "../../extensions/lifecycle-manifest.mjs";
16
17
  import { loadOrCreateProjectId, resumeStartupSession } from "./startup-session.mjs";
@@ -68,7 +69,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
68
69
  const memoryRoot = resolveMemoryRoot(config.memoryRoot, stateRoot);
69
70
  const profilePaths = defaultProfilePaths();
70
71
  ensureProfileFiles(profilePaths);
71
- const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
72
+ const memoryStore = new MarkdownMemoryStore({ root: memoryRoot, stateRoot });
72
73
  const remoteMemorySources = normalizeRemoteMemorySources(config);
73
74
  const currentProject = basename(cwd);
74
75
  const shellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
@@ -90,6 +91,8 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
90
91
  shellRuntime,
91
92
  historyStore: inputHistoryStore,
92
93
  });
94
+ await preloadSemanticMemoryRecall({ memoryStore, ui, logger });
95
+
93
96
  const outputRouter = createWorkspaceOutputRouter({
94
97
  ui,
95
98
  activeProjectId: currentProjectInfo.projectId,
@@ -166,7 +169,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
166
169
  stateRoot,
167
170
  memoryRoot,
168
171
  profilePaths,
169
- createMemoryStore: () => new MarkdownMemoryStore({ root: memoryRoot }),
172
+ createMemoryStore: () => new MarkdownMemoryStore({ root: memoryRoot, stateRoot }),
170
173
  provider,
171
174
  serviceTier,
172
175
  model,
@@ -30,7 +30,7 @@ function appendTimelineBlock(output, block) {
30
30
  output.addBlock({ type: "status", lines: [String(block.content ?? "")] });
31
31
  break;
32
32
  case "recall":
33
- output.addBlock({ type: "plain", lines: formatRecallLines(block.hints ?? []) });
33
+ output.addBlock({ type: "plain", lines: formatRecallLines(block.hints ?? [], block.report ?? null) });
34
34
  break;
35
35
  case "editDiff":
36
36
  output.addBlock({ type: "diff", path: block.path, diffLines: block.diffLines ?? [] });
@@ -1,18 +1,23 @@
1
+ import { formatScore } from "../../memory/markdown/markdown-recall.mjs";
1
2
  import { brightBlack } from "./ui-theme.mjs";
2
3
 
3
4
  const RECALL_ICON = "✦";
4
5
 
5
- export function formatRecallLines(hints = []) {
6
- if (!hints.length) return [];
6
+ export function formatRecallLines(hints = [], report = null) {
7
+ const candidates = report?.candidates ?? [];
8
+ if (!hints.length && !candidates.length) return [];
7
9
  const noun = hints.length === 1 ? "note" : "notes";
10
+ const threshold = Number.isFinite(report?.threshold) ? ` · threshold ${formatScore(report.threshold)}` : "";
11
+ const fallback = report?.vectorizerStatus === "fallback" ? " · fallback" : "";
8
12
  return [
9
- `${RECALL_ICON} Memory Recall · ${hints.length} ${noun}`,
10
- ...hints.flatMap(formatHintLines),
13
+ `${RECALL_ICON} Memory Recall · ${hints.length} ${noun}${threshold}${fallback}`,
14
+ ...(report?.warning ? [` ! ${report.warning}`] : []),
15
+ ...(candidates.length ? candidates : hints.map((hint) => ({ ...hint, recalled: true }))).flatMap(formatHintLines),
11
16
  ];
12
17
  }
13
18
 
14
- export function writeRecall({ output, hints = [] }) {
15
- const lines = formatRecallLines(hints);
19
+ export function writeRecall({ output, hints = [], report = null }) {
20
+ const lines = formatRecallLines(hints, report);
16
21
  lines.forEach((line) => {
17
22
  if (line.startsWith(" ")) output.writeln(brightBlack(line));
18
23
  else output.writeln(line);
@@ -21,7 +26,9 @@ export function writeRecall({ output, hints = [] }) {
21
26
 
22
27
  function formatHintLines(hint) {
23
28
  const title = hint.name || hint.id || "Untitled memory";
24
- const lines = [` • ${title}`];
29
+ const mark = hint.recalled === false ? "×" : "✓";
30
+ const score = `${formatScore(hint.score)} `;
31
+ const lines = [` ${mark} ${score}${title}`];
25
32
  if (hint.description) lines.push(` ${hint.description}`);
26
33
  return lines;
27
34
  }
@@ -4,19 +4,20 @@ import { formatRecallHints } from "../../memory/markdown-store.mjs";
4
4
  import { formatMessageAttachmentsForDisplay } from "../../session/attachment-display.mjs";
5
5
  import { formatShellHints } from "../../shell/hints.mjs";
6
6
 
7
- export function prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState = null }) {
7
+ export async function prepareTurnInput({ prompt, runner, memoryStore, currentProject, modeState = null }) {
8
8
  const engine = runner.engine ?? {};
9
9
  const carryoverAlreadyRendered = engine.hasRenderedPendingAssistantRecallHints?.() ?? false;
10
10
  const carryoverRecallHints = engine.takePendingAssistantRecallHints?.() ?? [];
11
- const userRecallHints = memoryStore.recallForUser(prompt, {
11
+ const userRecallHints = await memoryStore.recallForUser(prompt, {
12
12
  currentProject,
13
13
  excludedIds: engine.getRecentRecallMemoryIds?.() ?? [],
14
14
  });
15
+ const userRecallReport = memoryStore.lastUserRecallReport ?? null;
15
16
  const modePrompt = appendModeReminder(prompt, modeState?.get?.());
16
17
  const fullPrompt = appendPromptBlocks(
17
18
  modePrompt,
18
- formatRecallHints("user", userRecallHints),
19
- formatRecallHints("assistant", carryoverRecallHints),
19
+ formatRecallHints(userRecallHints),
20
+ formatRecallHints(carryoverRecallHints),
20
21
  formatShellHints(runner.shellRuntime),
21
22
  );
22
23
 
@@ -26,6 +27,7 @@ export function prepareTurnInput({ prompt, runner, memoryStore, currentProject,
26
27
  runOptions: { userRecallHints, currentProject },
27
28
  displayMessage: formatUserDisplayMessage(prompt),
28
29
  userRecallHints,
30
+ userRecallReport,
29
31
  carryoverRecallHints,
30
32
  shouldRenderCarryoverRecall: carryoverRecallHints.length > 0 && !carryoverAlreadyRendered,
31
33
  };
package/src/cli/ui.mjs CHANGED
@@ -212,8 +212,8 @@ export function createTuiUI({
212
212
  status: (text) => {
213
213
  ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.setOverlayStatus([brightBlack(`● ${text}`)]); requestRender();
214
214
  },
215
- recall: ({ hints }) => {
216
- ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.ensureNewline(); writeRecall({ output, hints }); requestRender();
215
+ recall: ({ hints, report }) => {
216
+ ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.ensureNewline(); writeRecall({ output, hints, report }); requestRender();
217
217
  },
218
218
 
219
219
  clearOutput: () => {
@@ -94,7 +94,7 @@ export function createTuiTimelineProjection() {
94
94
  blocks.push(createBlock("status", event.at, { content: String(first ?? "") }));
95
95
  break;
96
96
  case "recall":
97
- blocks.push(createBlock("recall", event.at, { hints: first?.hints ?? [] }));
97
+ blocks.push(createBlock("recall", event.at, { hints: first?.hints ?? [], report: first?.report ?? null }));
98
98
  break;
99
99
  case "editDiff":
100
100
  closeAssistantBlock(event.at);
@@ -168,14 +168,14 @@ export class ContextEngine {
168
168
  for (const turn of this.turns) {
169
169
  let block = `## Turn ${turn.index}\n` +
170
170
  `[user]\n${String(turn.userMessage ?? "")}\n`;
171
- const userRecall = formatRecallHints("user", turn.userRecallHints ?? []);
171
+ const userRecall = formatRecallHints(turn.userRecallHints ?? []);
172
172
  if (userRecall) block += `\n${userRecall}\n`;
173
173
  block += `\n[assistant]\n`;
174
174
  const assistantText = turn.assistantContext || turn.assistantMessage;
175
175
  if (assistantText) {
176
176
  block += `\n${String(assistantText ?? "")}\n`;
177
177
  }
178
- const assistantRecall = formatRecallHints("assistant", turn.assistantRecallHints ?? []);
178
+ const assistantRecall = formatRecallHints(turn.assistantRecallHints ?? []);
179
179
  if (assistantRecall) block += `\n${assistantRecall}\n`;
180
180
  entries.push(block);
181
181
  }