membot 0.8.0 → 0.10.0

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.
@@ -3,9 +3,29 @@ import { resolveEmbeddingWorkers } from "../context.ts";
3
3
  import { listDueRefreshes } from "../db/files.ts";
4
4
  import { withEmbedderPool } from "../ingest/embedder-pool.ts";
5
5
  import { colors } from "../output/formatter.ts";
6
+ import { isInteractive } from "../output/tty.ts";
6
7
  import { refreshOne } from "../refresh/runner.ts";
7
8
  import { defineOperation } from "./types.ts";
8
9
 
10
+ interface RefreshEntry {
11
+ logical_path: string;
12
+ status: "ok" | "unchanged" | "failed";
13
+ new_version_id?: string;
14
+ error?: string;
15
+ }
16
+
17
+ /** Render one refresh result as a persistent stderr / final-summary line. */
18
+ function formatEntryLine(p: RefreshEntry): string {
19
+ if (p.status === "ok") {
20
+ const ver = p.new_version_id ? colors.dim(`→ ${p.new_version_id}`) : "";
21
+ return `${colors.green("✓")} ${colors.cyan(p.logical_path)} ${ver}`;
22
+ }
23
+ if (p.status === "unchanged") {
24
+ return `${colors.dim("·")} ${colors.dim(p.logical_path)} ${colors.dim("(unchanged)")}`;
25
+ }
26
+ return `${colors.red("✗")} ${p.logical_path} ${colors.dim(p.error ?? "")}`;
27
+ }
28
+
9
29
  export const refreshOperation = defineOperation({
10
30
  name: "membot_refresh",
11
31
  cliName: "refresh",
@@ -31,22 +51,23 @@ export const refreshOperation = defineOperation({
31
51
  let updated = 0;
32
52
  let unchanged = 0;
33
53
  let failed = 0;
34
- const lines = result.processed.map((p) => {
35
- if (p.status === "ok") {
36
- updated++;
37
- const ver = p.new_version_id ? colors.dim(`→ ${p.new_version_id}`) : "";
38
- return `${colors.green("✓")} ${colors.cyan(p.logical_path)} ${ver}`;
39
- }
40
- if (p.status === "unchanged") {
41
- unchanged++;
42
- return `${colors.dim("·")} ${colors.dim(p.logical_path)} ${colors.dim("(unchanged)")}`;
43
- }
44
- failed++;
45
- return `${colors.red("✗")} ${p.logical_path} ${colors.dim(p.error ?? "")}`;
46
- });
54
+ for (const p of result.processed) {
55
+ if (p.status === "ok") updated++;
56
+ else if (p.status === "unchanged") unchanged++;
57
+ else failed++;
58
+ }
47
59
  const parts = [colors.green(`updated ${updated}`), colors.dim(`unchanged ${unchanged}`)];
48
60
  if (failed) parts.push(colors.red(`failed ${failed}`));
49
- return `${lines.join("\n")}\n${parts.join(", ")}`;
61
+ const summary = parts.join(", ");
62
+
63
+ // In interactive mode the per-entry results were already streamed to
64
+ // stderr via progress.entry() during the run; printing the same list
65
+ // to stdout would just duplicate the scrollback. Non-interactive
66
+ // callers (JSON, piped, CI) still get the full list.
67
+ if (isInteractive()) return summary;
68
+
69
+ const lines = result.processed.map(formatEntryLine);
70
+ return `${lines.join("\n")}\n${summary}`;
50
71
  },
51
72
  handler: async (input, ctx) => {
52
73
  // Per-command embedder pool: workers come up at the start of the
@@ -57,21 +78,19 @@ export const refreshOperation = defineOperation({
57
78
  const targets = input.logical_path
58
79
  ? [input.logical_path]
59
80
  : (await listDueRefreshes(ctx.db)).map((r) => r.logical_path);
60
- const out: Array<{
61
- logical_path: string;
62
- status: "ok" | "unchanged" | "failed";
63
- new_version_id?: string;
64
- error?: string;
65
- }> = [];
81
+ const out: RefreshEntry[] = [];
66
82
  ctx.progress.start(targets.length, "refresh");
67
83
  for (const path of targets) {
68
- ctx.progress.tick(path);
84
+ ctx.progress.setLabel(path);
85
+ let entry: RefreshEntry;
69
86
  try {
70
- const r = await refreshOne(ctx, path, input.force, (sublabel) => ctx.progress.update(sublabel));
71
- out.push(r);
87
+ entry = await refreshOne(ctx, path, input.force, (sublabel) => ctx.progress.update(sublabel));
72
88
  } catch (err) {
73
- out.push({ logical_path: path, status: "failed", error: err instanceof Error ? err.message : String(err) });
89
+ entry = { logical_path: path, status: "failed", error: err instanceof Error ? err.message : String(err) };
74
90
  }
91
+ out.push(entry);
92
+ ctx.progress.tick(path);
93
+ ctx.progress.entry(formatEntryLine(entry));
75
94
  }
76
95
  ctx.progress.done(`refresh: ${out.filter((r) => r.status === "ok").length}/${out.length} updated`);
77
96
  return { processed: out, count: out.length };
@@ -18,6 +18,27 @@ export function renderResult<T>(result: T, opts: { console_formatter?: (result:
18
18
  return JSON.stringify(result, null, 2);
19
19
  }
20
20
 
21
+ /**
22
+ * Format a byte count as a short human-readable string: 5654 → `5.5 KB`,
23
+ * 14_859 → `14.5 KB`, 2_345_678 → `2.2 MB`. Uses 1024-based units (binary
24
+ * prefixes) since file sizes on disk are typically reported that way.
25
+ * Negative or non-finite inputs render as `0 B`.
26
+ */
27
+ export function formatBytes(n: number): string {
28
+ if (!Number.isFinite(n) || n < 0) return "0 B";
29
+ if (n < 1024) return `${n} B`;
30
+ const units = ["KB", "MB", "GB", "TB"] as const;
31
+ let value = n / 1024;
32
+ let unit: string = units[0];
33
+ for (let i = 1; i < units.length && value >= 1024; i++) {
34
+ value /= 1024;
35
+ unit = units[i] as string;
36
+ }
37
+ // One decimal until 100, then round to integer (so the column stays narrow).
38
+ const formatted = value < 100 ? value.toFixed(1) : `${Math.round(value)}`;
39
+ return `${formatted} ${unit}`;
40
+ }
41
+
21
42
  /**
22
43
  * Pretty-print a 2D array of cells as an aligned table. Column widths are
23
44
  * computed from the visible (escape-stripped) length of each cell so coloured
@@ -9,6 +9,17 @@ export interface Spinner {
9
9
  stop(): void;
10
10
  }
11
11
 
12
+ /**
13
+ * Anything occupying a fixed area of stderr that needs to be torn down before
14
+ * the logger writes a stray line, then redrawn afterward. nanospinner's
15
+ * single-line spinner and progress.ts's multi-line worker view both implement
16
+ * this so log/info/warn lines don't shred the live display.
17
+ */
18
+ export interface LiveArea {
19
+ clear(): void;
20
+ render(): void;
21
+ }
22
+
12
23
  const NOOP_SPINNER: Spinner = { update() {}, success() {}, error() {}, stop() {} };
13
24
 
14
25
  /**
@@ -20,6 +31,7 @@ const NOOP_SPINNER: Spinner = { update() {}, success() {}, error() {}, stop() {}
20
31
  class Logger {
21
32
  private static instance: Logger;
22
33
  private activeSpinner: ReturnType<typeof createSpinner> | null = null;
34
+ private activeLiveArea: LiveArea | null = null;
23
35
 
24
36
  /** Singleton accessor. Use the exported `logger` const instead in normal code. */
25
37
  static getInstance(): Logger {
@@ -31,7 +43,24 @@ class Logger {
31
43
  return useColor() ? fn(msg) : msg;
32
44
  }
33
45
 
46
+ /**
47
+ * Register a multi-line live display. Logger will `clear()` it before any
48
+ * stderr write and `render()` it after, so log lines don't punch through
49
+ * the live area. Pass null to deregister. Mutually exclusive with the
50
+ * nanospinner path (only one live thing on stderr at a time).
51
+ */
52
+ setActiveLiveArea(area: LiveArea | null): void {
53
+ this.activeLiveArea = area;
54
+ }
55
+
34
56
  private writeStderr(msg: string): void {
57
+ const area = this.activeLiveArea;
58
+ if (area) {
59
+ area.clear();
60
+ process.stderr.write(`${msg}\n`);
61
+ area.render();
62
+ return;
63
+ }
35
64
  if (this.activeSpinner) {
36
65
  this.activeSpinner.clear();
37
66
  process.stderr.write(`${msg}\n`);
@@ -66,6 +95,13 @@ class Logger {
66
95
 
67
96
  /** Raw stderr write, no formatting added. Spinner-aware. */
68
97
  writeRaw(msg: string): void {
98
+ const area = this.activeLiveArea;
99
+ if (area) {
100
+ area.clear();
101
+ process.stderr.write(msg);
102
+ area.render();
103
+ return;
104
+ }
69
105
  if (this.activeSpinner) {
70
106
  this.activeSpinner.clear();
71
107
  process.stderr.write(msg);