march-cli 0.1.42 → 0.1.46

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/agent/code-search/cache.mjs +16 -7
  3. package/src/agent/code-search/engine.mjs +9 -2
  4. package/src/agent/code-search/retrieval/resilient-vectorizer.mjs +59 -0
  5. package/src/agent/code-search/retrieval/safetensors.mjs +16 -10
  6. package/src/agent/code-search/scanner.mjs +11 -5
  7. package/src/agent/code-search/tool.mjs +11 -5
  8. package/src/agent/model-payload-dumper.mjs +1 -1
  9. package/src/agent/runner/payload/provider-payload-transform.mjs +59 -0
  10. package/src/agent/runner/recall/mid-turn-recall-bridge.mjs +23 -0
  11. package/src/agent/runner.mjs +28 -27
  12. package/src/agent/runtime/remote-ui-client.mjs +1 -1
  13. package/src/agent/runtime/resource/context-resource-loader.mjs +17 -0
  14. package/src/agent/runtime/runtime-factory.mjs +5 -1
  15. package/src/agent/runtime/state/runner-state.mjs +10 -3
  16. package/src/agent/runtime/ui-event-bridge.mjs +2 -2
  17. package/src/agent/turn/turn-runner.mjs +35 -24
  18. package/src/cli/fallback-ui.mjs +2 -2
  19. package/src/cli/repl-loop.mjs +9 -8
  20. package/src/cli/startup/app-runtime.mjs +4 -2
  21. package/src/cli/tui/input/mouse-selection-controller.mjs +19 -0
  22. package/src/cli/tui/output/selectable-copy.mjs +3 -3
  23. package/src/cli/tui/output/timeline-block-restore.mjs +1 -1
  24. package/src/cli/tui/output-buffer.mjs +18 -0
  25. package/src/cli/tui/recall-rendering.mjs +38 -9
  26. package/src/cli/tui/selection/ansi-range.mjs +88 -0
  27. package/src/cli/tui/selection-screen.mjs +31 -99
  28. package/src/cli/turn/turn-input-preparer.mjs +15 -6
  29. package/src/cli/ui.mjs +2 -2
  30. package/src/cli/workspace/tui-timeline-projection.mjs +1 -1
  31. package/src/context/engine.mjs +15 -5
  32. package/src/context/system-core/base.md +1 -1
  33. package/src/memory/markdown/markdown-format.mjs +0 -17
  34. package/src/memory/markdown/markdown-recall.mjs +11 -19
  35. package/src/memory/markdown/semantic-preload.mjs +26 -0
  36. package/src/memory/markdown/semantic-recall.mjs +169 -0
  37. package/src/memory/markdown/sqlite-index.mjs +1 -13
  38. package/src/memory/markdown-store.mjs +34 -54
  39. package/src/web-ui/dist/assets/{index-BQtl1uQs.css → index-BG1Pxf1k.css} +1 -1
  40. package/src/web-ui/dist/assets/{index-DrlJis_D.js → index-C0xOHlDz.js} +1 -1
  41. package/src/web-ui/dist/index.html +2 -2
  42. package/src/web-ui/runtime-host.mjs +18 -3
  43. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +27 -0
  44. package/src/web-ui/src/model.ts +18 -0
  45. package/src/web-ui/src/runtime/client.ts +2 -1
  46. package/src/web-ui/src/runtime/runtimeTimeline.ts +5 -0
  47. package/src/web-ui/src/styles/shell.css +7 -0
  48. package/src/web-ui/src/timelineAdapter.ts +2 -0
@@ -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: ({ hints, report, variant }) => eventBus.emit({ type: "recall", hints, report, variant }),
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, variant: event.variant });
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;
@@ -20,14 +20,15 @@ export async function runRunnerTurn({
20
20
  autoNameSession,
21
21
  contextMode = "rebuild",
22
22
  recordHistory = null,
23
+ trackMidTurnRecallInjection = null,
23
24
  }) {
24
25
  const {
25
26
  userRecallHints = [],
26
- currentProject = "",
27
27
  } = options;
28
28
  const activeSession = sessionBinding.get();
29
29
  const turnState = createTurnEventState();
30
30
  const midTurnRecallHints = [];
31
+ const midTurnRecallTasks = [];
31
32
  const idleWatchdog = createModelStreamIdleWatchdog({ session: activeSession, logger, setPhase });
32
33
  ui.turnStart();
33
34
  setPhase?.("subscribed");
@@ -57,12 +58,9 @@ export async function runRunnerTurn({
57
58
  }
58
59
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
59
60
  if (event.type === "tool_execution_start") {
60
- const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
61
- if (hints.length > 0) {
62
- midTurnRecallHints.push(...hints);
63
- queueMidTurnRecallHints(activeSession, hints, logger);
64
- ui.recall?.({ source: "assistant", hints });
65
- }
61
+ const task = flushMidTurnAssistantRecall({ memoryStore, engine, turnState, activeSession, ui, logger, midTurnRecallHints, trackMidTurnRecallInjection });
62
+ trackMidTurnRecallInjection?.({ task });
63
+ midTurnRecallTasks.push(task);
66
64
  }
67
65
  });
68
66
 
@@ -90,11 +88,11 @@ export async function runRunnerTurn({
90
88
  }
91
89
 
92
90
  setPhase?.("finalizing");
93
- finalizeTurn({
91
+ await Promise.allSettled(midTurnRecallTasks);
92
+ await finalizeTurn({
94
93
  prompt,
95
94
  userMessage,
96
95
  userRecallHints,
97
- currentProject,
98
96
  memoryStore,
99
97
  engine,
100
98
  ui,
@@ -172,17 +170,17 @@ function throwIfAssistantEndedWithError(turnState) {
172
170
  }
173
171
 
174
172
  function queueMidTurnRecallHints(session, hints, logger) {
175
- const content = formatRecallHints("assistant", hints);
176
- if (!content) return;
177
- const injected = session.sendCustomMessage?.({
173
+ const content = formatRecallHints(hints);
174
+ if (!content) return null;
175
+ const task = Promise.resolve(session.sendCustomMessage?.({
178
176
  customType: "march.recall",
179
177
  content,
180
178
  display: false,
181
- details: { source: "assistant" },
182
- }, { deliverAs: "steer" });
183
- void injected?.catch?.((err) => {
179
+ details: { type: "recall" },
180
+ }, { deliverAs: "steer" })).catch((err) => {
184
181
  logger?.debug("memory.mid_turn_recall.inject_failed", { errorMessage: err?.message ?? String(err) });
185
182
  });
183
+ return { content, task };
186
184
  }
187
185
 
188
186
  function logSessionEvent(logger, event) {
@@ -209,11 +207,11 @@ function logSessionEvent(logger, event) {
209
207
  });
210
208
  }
211
209
 
212
- function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentMarchSessionState, autoNameSession, recordHistory }) {
210
+ async function finalizeTurn({ prompt, userMessage, userRecallHints, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentMarchSessionState, autoNameSession, recordHistory }) {
213
211
  closeAssistantReply({ ui, state: turnState });
214
- const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
215
- engine.setPendingAssistantRecallHints?.(assistantRecallHints);
216
- const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
212
+ const assistantRecall = await flushAssistantRecall({ memoryStore, engine, turnState });
213
+ engine.setPendingAssistantRecallHints?.(assistantRecall.hints, assistantRecall.report);
214
+ const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecall.hints]);
217
215
 
218
216
  const turn = engine.recordTurn({
219
217
  userMessage: userMessage ?? prompt.slice(0, 300),
@@ -228,17 +226,30 @@ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, me
228
226
  syncCurrentMarchSessionState();
229
227
  }
230
228
 
231
- function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
232
- if (!memoryStore) return [];
229
+ async function flushAssistantRecall({ memoryStore, engine, turnState }) {
230
+ if (!memoryStore) return { hints: [], report: null };
233
231
  const text = assistantRecallDeltaText(turnState);
234
232
  advanceAssistantRecallCursor(turnState);
235
- if (!text.trim()) return [];
236
- return memoryStore.recallForAssistant(text, {
237
- currentProject,
233
+ if (!text.trim()) return { hints: [], report: null };
234
+ return await memoryStore.recallForAssistant(text, {
238
235
  excludedIds: engine.getRecentRecallMemoryIds?.() ?? [],
239
236
  });
240
237
  }
241
238
 
239
+ async function flushMidTurnAssistantRecall({ memoryStore, engine, turnState, activeSession, ui, logger, midTurnRecallHints, trackMidTurnRecallInjection }) {
240
+ try {
241
+ const { hints, report } = await flushAssistantRecall({ memoryStore, engine, turnState });
242
+ if (hints.length > 0) {
243
+ midTurnRecallHints.push(...hints);
244
+ const injection = queueMidTurnRecallHints(activeSession, hints, logger);
245
+ if (injection) trackMidTurnRecallInjection?.(injection);
246
+ }
247
+ if (report) ui.recall?.({ hints, report, variant: "assistant" });
248
+ } catch (err) {
249
+ logger?.debug("memory.mid_turn_recall.failed", { errorMessage: err?.message ?? String(err) });
250
+ }
251
+ }
252
+
242
253
  function assistantRecallDeltaText(turnState) {
243
254
  const cursor = turnState.recallCursor ?? { draftLength: 0, thinkingLength: 0 };
244
255
  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, variant }) => {
113
113
  ensureNewline();
114
- for (const line of formatRecallLines(hints)) stdout.write(`${brightBlack(line)}\n`);
114
+ for (const line of formatRecallLines(hints, report, { variant })) 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, report: turnInput.carryoverRecallReport, variant: "assistant" });
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, report: turnInput.carryoverRecallReport, variant: "assistant" });
192
192
  setTurnRunning(true);
193
193
  refreshStatusBar.startWorking?.();
194
194
  const result = await runner.runTurn(turnInput.fullPrompt, turnInput.userMessage, turnInput.runOptions);
@@ -215,7 +215,8 @@ async function handleTurnLifecycleAction(action, { runner, ui }) {
215
215
  function renderPendingAssistantRecallPreview({ runner, ui }) {
216
216
  if (runner.engine.hasRenderedPendingAssistantRecallHints?.()) return;
217
217
  const hints = runner.engine.peekPendingAssistantRecallHints?.() ?? [];
218
- if (hints.length === 0) return;
219
- ui.recall?.({ source: "assistant", hints });
218
+ const report = runner.engine.peekPendingAssistantRecallReport?.() ?? null;
219
+ if (hints.length === 0 && !report) return;
220
+ ui.recall?.({ hints, report, variant: "assistant" });
220
221
  runner.engine.markPendingAssistantRecallHintsRendered?.();
221
222
  }
@@ -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 { startSemanticMemoryRecallPreload } 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;
@@ -140,6 +141,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
140
141
  return { ok: false, code: 1, logger };
141
142
  }
142
143
  syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot);
144
+ startSemanticMemoryRecallPreload({ memoryStore, logger, delayMs: 1000 });
143
145
 
144
146
  const initialRuntime = {
145
147
  project: currentProjectInfo,
@@ -166,7 +168,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
166
168
  stateRoot,
167
169
  memoryRoot,
168
170
  profilePaths,
169
- createMemoryStore: () => new MarkdownMemoryStore({ root: memoryRoot }),
171
+ createMemoryStore: () => new MarkdownMemoryStore({ root: memoryRoot, stateRoot }),
170
172
  provider,
171
173
  serviceTier,
172
174
  model,
@@ -12,6 +12,7 @@ export function createMouseSelectionController({
12
12
  writeClipboard,
13
13
  requestRender,
14
14
  }) {
15
+ let leftDown = null;
15
16
  function copySelectionText(text) {
16
17
  if (!text) return false;
17
18
  let result;
@@ -43,6 +44,12 @@ export function createMouseSelectionController({
43
44
  requestRender();
44
45
  }
45
46
 
47
+ function toggleOutputToolCard(mouse) {
48
+ const hit = selection.hitTest?.(mouse);
49
+ if (hit?.regionId !== "output") return false;
50
+ return output.toggleToolCardAtVisibleRow?.(hit.row, terminal.columns || 80) === true;
51
+ }
52
+
46
53
  return {
47
54
  handleMouseInput(data) {
48
55
  const mouse = parseMouseEvent(data);
@@ -56,6 +63,7 @@ export function createMouseSelectionController({
56
63
  return { consume: true };
57
64
  }
58
65
  if (mouse?.type === "down" && mouse.button === 0) {
66
+ leftDown = mouse;
59
67
  selection.start(mouse);
60
68
  requestRender();
61
69
  return { consume: true };
@@ -66,7 +74,14 @@ export function createMouseSelectionController({
66
74
  return { consume: true };
67
75
  }
68
76
  if (mouse?.type === "up") {
77
+ const wasClick = isSamePoint(leftDown, mouse);
78
+ leftDown = null;
69
79
  selection.finish(mouse, { clear: false });
80
+ if (wasClick && toggleOutputToolCard(mouse)) {
81
+ selection.clear();
82
+ requestRender();
83
+ return { consume: true };
84
+ }
70
85
  requestRender();
71
86
  return { consume: true };
72
87
  }
@@ -84,6 +99,10 @@ export function createMouseSelectionController({
84
99
  };
85
100
  }
86
101
 
102
+ function isSamePoint(a, b) {
103
+ return Boolean(a && b && a.row === b.row && a.col === b.col && a.button === b.button);
104
+ }
105
+
87
106
  function compactStatusMessage(message) {
88
107
  const text = String(message || "unknown error").replace(/\s+/g, " ").trim();
89
108
  return text.length > 80 ? `${text.slice(0, 79)}…` : text;
@@ -4,7 +4,7 @@ import { renderMarkdown } from "../markdown-renderer.mjs";
4
4
 
5
5
  export function appendSelectableEntries(entries, block, lines, width) {
6
6
  if (block.type !== "markdown") {
7
- for (const line of lines) entries.push({ line, source: null, codeSource: null, baseRow: entries.length });
7
+ for (const line of lines) entries.push({ line, source: null, codeSource: null, block, baseRow: entries.length });
8
8
  return;
9
9
  }
10
10
  const source = { kind: "markdown", text: block.text, startRow: entries.length, endRow: entries.length + lines.length - 1 };
@@ -18,10 +18,10 @@ export function appendSelectableEntries(entries, block, lines, width) {
18
18
  }
19
19
 
20
20
  export function sliceEntriesWithTail(baseEntries, tailLine, range) {
21
- if (!range) return tailLine == null ? baseEntries : [...baseEntries, { line: tailLine, source: null, codeSource: null, baseRow: baseEntries.length }];
21
+ if (!range) return tailLine == null ? baseEntries : [...baseEntries, { line: tailLine, source: null, codeSource: null, block: null, baseRow: baseEntries.length }];
22
22
  const { start, end } = range;
23
23
  const visible = baseEntries.slice(start, Math.min(end, baseEntries.length));
24
- if (tailLine != null && end > baseEntries.length) visible.push({ line: tailLine, source: null, codeSource: null, baseRow: baseEntries.length });
24
+ if (tailLine != null && end > baseEntries.length) visible.push({ line: tailLine, source: null, codeSource: null, block: null, baseRow: baseEntries.length });
25
25
  return visible;
26
26
  }
27
27
 
@@ -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 ?? [] });
@@ -206,6 +206,15 @@ export class OutputBuffer {
206
206
  return changed;
207
207
  }
208
208
 
209
+ toggleToolCardAtVisibleRow(row, width) {
210
+ const entry = this._visibleEntryAt(row, width);
211
+ const block = entry?.block;
212
+ if (block?.type !== "tool-card") return false;
213
+ block.expanded = !block.expanded;
214
+ this._invalidateBaseLines();
215
+ return true;
216
+ }
217
+
209
218
  invalidate() { this._invalidateBaseLines(); }
210
219
 
211
220
  _invalidateBaseLines() {
@@ -232,6 +241,15 @@ export class OutputBuffer {
232
241
  return brightBlack(`${SPINNER_FRAMES[this.spinnerIdx]} ${this.spinnerText}`);
233
242
  }
234
243
 
244
+ _visibleEntryAt(row, width) {
245
+ const visibleRow = Math.trunc(row);
246
+ if (visibleRow < 0) return null;
247
+ const baseEntries = this._renderBaseEntries(width);
248
+ const tailLine = this.spinning ? this._spinnerLine() : null;
249
+ const entries = sliceEntriesWithTail(baseEntries, tailLine, this.scrollState.sliceRange());
250
+ return entries[visibleRow] ?? null;
251
+ }
252
+
235
253
  _renderBaseLines(width) {
236
254
  const cached = this._baseLinesCache.get(width);
237
255
  if (cached) return cached;
@@ -1,27 +1,56 @@
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 [];
7
- const noun = hints.length === 1 ? "note" : "notes";
6
+ export function formatRecallLines(hints = [], report = null, { variant = "user" } = {}) {
7
+ if (variant === "assistant") return formatAssistantRecallLines(hints, report);
8
+ const candidates = report?.candidates ?? [];
9
+ const displayed = candidates.length ? candidates : hints.map((hint) => ({ ...hint, recalled: true }));
10
+ if (!hints.length && !displayed.length) return [];
11
+ const threshold = Number.isFinite(report?.threshold) ? ` · threshold ${formatScore(report.threshold)}` : "";
12
+ const fallback = report?.vectorizerStatus === "fallback" ? " · fallback" : "";
8
13
  return [
9
- `${RECALL_ICON} Memory Recall · ${hints.length} ${noun}`,
10
- ...hints.flatMap(formatHintLines),
14
+ `${RECALL_ICON} Memory Recall · ${recallSummary(hints, displayed)}${threshold}${fallback}`,
15
+ ...(report?.warning ? [` ! ${report.warning}`] : []),
16
+ ...displayed.flatMap(formatHintLines),
11
17
  ];
12
18
  }
13
19
 
14
- export function writeRecall({ output, hints = [] }) {
15
- const lines = formatRecallLines(hints);
20
+ export function writeRecall({ output, hints = [], report = null, variant = "user" }) {
21
+ const lines = formatRecallLines(hints, report, { variant });
16
22
  lines.forEach((line) => {
17
- if (line.startsWith(" ")) output.writeln(brightBlack(line));
23
+ if (variant === "assistant" || line.startsWith(" ")) output.writeln(brightBlack(line));
18
24
  else output.writeln(line);
19
25
  });
20
26
  }
21
27
 
28
+ function formatAssistantRecallLines(hints, report) {
29
+ const candidates = (report?.candidates?.length ? report.candidates : hints.map((hint) => ({ ...hint, recalled: true }))).slice(0, 3);
30
+ const threshold = Number.isFinite(report?.threshold) ? ` · threshold ${formatScore(report.threshold)}` : "";
31
+ const fallback = report?.vectorizerStatus === "fallback" ? " · fallback" : "";
32
+ return [
33
+ `${RECALL_ICON} Memory Recall · ${recallSummary(hints, report?.candidates ?? candidates)}${threshold}${fallback}`,
34
+ ...(report?.warning ? [` ! ${report.warning}`] : []),
35
+ ...(candidates.length ? candidates.map(formatCompactHintLine) : [" no candidates"]),
36
+ ];
37
+ }
38
+
39
+ function recallSummary(hints, candidates) {
40
+ return `${hints.length} recalled · ${candidates.length} ${candidates.length === 1 ? "candidate" : "candidates"}`;
41
+ }
42
+
43
+ function formatCompactHintLine(hint) {
44
+ const title = hint.name || hint.id || "Untitled memory";
45
+ const mark = hint.recalled === false ? "×" : "✓";
46
+ return ` ${mark} ${formatScore(hint.score)} ${title}`;
47
+ }
48
+
22
49
  function formatHintLines(hint) {
23
50
  const title = hint.name || hint.id || "Untitled memory";
24
- const lines = [` • ${title}`];
51
+ const mark = hint.recalled === false ? "×" : "✓";
52
+ const score = `${formatScore(hint.score)} `;
53
+ const lines = [` ${mark} ${score}${title}`];
25
54
  if (hint.description) lines.push(` ${hint.description}`);
26
55
  return lines;
27
56
  }
@@ -0,0 +1,88 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ const INVERSE = "\x1b[7m";
4
+ const RESET = "\x1b[0m";
5
+
6
+ export function highlightAnsiLine(line, startCol, endCol) {
7
+ const { before, selected, after, activeAtStart, activeAtEnd } = splitAnsiColumns(line, startCol, endCol);
8
+ return `${before}${INVERSE}${activeAtStart}${keepInverseAfterReset(selected)}${RESET}${activeAtEnd}${after}`;
9
+ }
10
+
11
+ export function sliceColumns(text, startCol, endCol) {
12
+ let col = 0;
13
+ let result = "";
14
+ for (const ch of String(text ?? "")) {
15
+ const next = col + visibleWidth(ch);
16
+ if (next > startCol && col < endCol) result += ch;
17
+ col = next;
18
+ if (col >= endCol) break;
19
+ }
20
+ return result;
21
+ }
22
+
23
+ function keepInverseAfterReset(text) {
24
+ return String(text ?? "").replace(/\x1b\[([0-9;]*)m/g, (seq, body) => {
25
+ const params = body === "" ? ["0"] : body.split(";");
26
+ return params.includes("0") ? `${seq}${INVERSE}` : seq;
27
+ });
28
+ }
29
+
30
+ function splitAnsiColumns(text, startCol, endCol) {
31
+ let col = 0;
32
+ let i = 0;
33
+ let before = "";
34
+ let selected = "";
35
+ let after = "";
36
+ let active = "";
37
+ let activeAtStart = "";
38
+ let activeAtEnd = "";
39
+ let capturedStart = false;
40
+ let capturedEnd = false;
41
+ const source = String(text ?? "");
42
+
43
+ while (i < source.length) {
44
+ const ansi = readAnsi(source, i);
45
+ if (ansi) {
46
+ active = updateActiveSgr(active, ansi);
47
+ if (col < startCol) before += ansi;
48
+ else if (col < endCol) selected += ansi;
49
+ else after += ansi;
50
+ i += ansi.length;
51
+ continue;
52
+ }
53
+
54
+ const ch = source[i];
55
+ if (!capturedStart && col >= startCol) {
56
+ activeAtStart = active;
57
+ capturedStart = true;
58
+ }
59
+ if (!capturedEnd && col >= endCol) {
60
+ activeAtEnd = active;
61
+ capturedEnd = true;
62
+ }
63
+
64
+ const next = col + visibleWidth(ch);
65
+ if (next <= startCol) before += ch;
66
+ else if (col >= endCol) after += ch;
67
+ else selected += ch;
68
+ col = next;
69
+ i += 1;
70
+ }
71
+
72
+ if (!capturedStart) activeAtStart = active;
73
+ if (!capturedEnd) activeAtEnd = active;
74
+ return { before, selected, after, activeAtStart, activeAtEnd };
75
+ }
76
+
77
+ function readAnsi(text, offset) {
78
+ if (text[offset] !== "\x1b") return null;
79
+ const match = text.slice(offset).match(/^\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/);
80
+ return match?.[0] ?? null;
81
+ }
82
+
83
+ function updateActiveSgr(active, seq) {
84
+ if (!seq.startsWith("\x1b[") || !seq.endsWith("m")) return active;
85
+ const body = seq.slice(2, -1);
86
+ if (body === "" || body.split(";").includes("0")) return "";
87
+ return seq;
88
+ }
@@ -1,8 +1,7 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { highlightAnsiLine, sliceColumns } from "./selection/ansi-range.mjs";
2
3
 
3
4
  const CONTROL_RE = /\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
4
- const INVERSE = "\x1b[7m";
5
- const RESET = "\x1b[0m";
6
5
 
7
6
  export class ScreenSelection {
8
7
  constructor() {
@@ -118,6 +117,12 @@ export class ScreenSelection {
118
117
  });
119
118
  }
120
119
 
120
+ hitTest(point) {
121
+ const hit = hitRegion(point, this.regions);
122
+ if (!hit) return null;
123
+ return { regionId: hit.region.id, row: hit.localRow, col: hit.localCol };
124
+ }
125
+
121
126
  _plainLine(row) {
122
127
  if (!this._plainLines.has(row)) this._plainLines.set(row, stripAnsi(this.lines[row] ?? ""));
123
128
  return this._plainLines.get(row);
@@ -167,24 +172,19 @@ function localRange(range, region) {
167
172
  }
168
173
 
169
174
  function normalizePoint({ row, col }, regions, clamp) {
175
+ const hit = hitRegion({ row, col }, regions);
176
+ if (hit) {
177
+ const maxCol = Number.isFinite(hit.region.width) ? hit.region.width : Infinity;
178
+ return {
179
+ row: hit.region.docStart + hit.localRow,
180
+ col: clampNumber(hit.localCol, 0, maxCol),
181
+ };
182
+ }
183
+ if (!clamp) return null;
184
+
170
185
  const screenRow = Math.trunc(row) - 1;
171
- const screenCol = Math.trunc(col) - 1;
172
186
  if (regions.length === 0) return null;
173
187
 
174
- for (const region of regions) {
175
- const localRow = screenRow - region.topRow;
176
- const localCol = screenCol - region.leftCol;
177
- const maxCol = Number.isFinite(region.width) ? region.width : Infinity;
178
- if (localRow >= 0 && localRow < region.lines.length) {
179
- if (!clamp && (localCol < 0 || localCol > maxCol)) return null;
180
- return {
181
- row: region.docStart + localRow,
182
- col: clampNumber(localCol, 0, maxCol),
183
- };
184
- }
185
- }
186
-
187
- if (!clamp) return null;
188
188
  const first = regions[0];
189
189
  const last = regions.at(-1);
190
190
  if (screenRow < first.topRow) return { row: first.docStart, col: 0 };
@@ -205,94 +205,26 @@ function normalizePoint({ row, col }, regions, clamp) {
205
205
  return nearest ? { row: nearest.row, col: nearest.col } : null;
206
206
  }
207
207
 
208
- function comparePoints(a, b) {
209
- if (a.row !== b.row) return a.row - b.row;
210
- return a.col - b.col;
211
- }
212
-
213
- function highlightAnsiLine(line, startCol, endCol) {
214
- const { before, selected, after, activeAtStart, activeAtEnd } = splitAnsiColumns(line, startCol, endCol);
215
- return `${before}${INVERSE}${activeAtStart}${keepInverseAfterReset(selected)}${RESET}${activeAtEnd}${after}`;
216
- }
217
-
218
- function keepInverseAfterReset(text) {
219
- return String(text ?? "").replace(/\x1b\[([0-9;]*)m/g, (seq, body) => {
220
- const params = body === "" ? ["0"] : body.split(";");
221
- return params.includes("0") ? `${seq}${INVERSE}` : seq;
222
- });
223
- }
224
-
225
- function sliceColumns(text, startCol, endCol) {
226
- let col = 0;
227
- let result = "";
228
- for (const ch of String(text ?? "")) {
229
- const next = col + visibleWidth(ch);
230
- if (next > startCol && col < endCol) result += ch;
231
- col = next;
232
- if (col >= endCol) break;
208
+ function hitRegion({ row, col }, regions) {
209
+ const screenRow = Math.trunc(row) - 1;
210
+ const screenCol = Math.trunc(col) - 1;
211
+ for (const region of regions) {
212
+ const localRow = screenRow - region.topRow;
213
+ const localCol = screenCol - region.leftCol;
214
+ const maxCol = Number.isFinite(region.width) ? region.width : Infinity;
215
+ if (localRow < 0 || localRow >= region.lines.length) continue;
216
+ if (localCol < 0 || localCol > maxCol) continue;
217
+ return { region, localRow, localCol };
233
218
  }
234
- return result;
219
+ return null;
235
220
  }
236
221
 
237
- function splitAnsiColumns(text, startCol, endCol) {
238
- let col = 0;
239
- let i = 0;
240
- let before = "";
241
- let selected = "";
242
- let after = "";
243
- let active = "";
244
- let activeAtStart = "";
245
- let activeAtEnd = "";
246
- let capturedStart = false;
247
- let capturedEnd = false;
248
- const source = String(text ?? "");
249
-
250
- while (i < source.length) {
251
- const ansi = readAnsi(source, i);
252
- if (ansi) {
253
- active = updateActiveSgr(active, ansi);
254
- if (col < startCol) before += ansi;
255
- else if (col < endCol) selected += ansi;
256
- else after += ansi;
257
- i += ansi.length;
258
- continue;
259
- }
260
-
261
- const ch = source[i];
262
- if (!capturedStart && col >= startCol) {
263
- activeAtStart = active;
264
- capturedStart = true;
265
- }
266
- if (!capturedEnd && col >= endCol) {
267
- activeAtEnd = active;
268
- capturedEnd = true;
269
- }
270
-
271
- const next = col + visibleWidth(ch);
272
- if (next <= startCol) before += ch;
273
- else if (col >= endCol) after += ch;
274
- else selected += ch;
275
- col = next;
276
- i += 1;
277
- }
278
-
279
- if (!capturedStart) activeAtStart = active;
280
- if (!capturedEnd) activeAtEnd = active;
281
- return { before, selected, after, activeAtStart, activeAtEnd };
222
+ function comparePoints(a, b) {
223
+ if (a.row !== b.row) return a.row - b.row;
224
+ return a.col - b.col;
282
225
  }
283
226
 
284
- function readAnsi(text, offset) {
285
- if (text[offset] !== "\x1b") return null;
286
- const match = text.slice(offset).match(/^\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/);
287
- return match?.[0] ?? null;
288
- }
289
227
 
290
- function updateActiveSgr(active, seq) {
291
- if (!seq.startsWith("\x1b[") || !seq.endsWith("m")) return active;
292
- const body = seq.slice(2, -1);
293
- if (body === "" || body.split(";").includes("0")) return "";
294
- return seq;
295
- }
296
228
 
297
229
  function clampNumber(value, min, max) {
298
230
  return Math.min(max, Math.max(min, value));