@takara-ai/miru-code 1.0.5 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,11 +43,11 @@ Interactive TUI — **↑↓** move, **space** toggle, **a** all, **enter** conf
43
43
 
44
44
  | Integration | What it does |
45
45
  |-------------|----------------|
46
- | MCP server | `search` and `find_related` tools in the agent |
46
+ | MCP server | `search`, `expand`, and `find_related` tools in the agent |
47
47
  | Instructions | Search policy in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` |
48
48
  | Sub-agent | Dedicated `miru-code` agent file |
49
49
  | Cursor rules | Always-on `.cursor/rules/miru-code.mdc` (Cursor only) |
50
- | Search hooks | Block built-in Grep/Glob and redirect to Miru MCP |
50
+ | Search hooks *(experimental)* | Block built-in Grep/Glob and redirect to Miru MCP |
51
51
 
52
52
  Restart the IDE when done.
53
53
 
@@ -57,7 +57,7 @@ miru uninstall # remove miru config
57
57
 
58
58
  **Supported:** Cursor · Claude Code · Gemini CLI · Kiro · OpenCode · GitHub Copilot · Codex · VS Code · Visual Studio (Windows) · Windsurf / Devin Desktop
59
59
 
60
- | IDE | MCP | Instructions / rules | Hooks |
60
+ | IDE | MCP | Instructions / rules | Hooks *(experimental)* |
61
61
  |-----|-----|------------------------|-------|
62
62
  | Cursor | `~/.cursor/mcp.json` | `~/.cursor/rules/miru-code.mdc` | `~/.cursor/hooks.json` |
63
63
  | Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` | `~/.claude/settings.json` |
@@ -70,13 +70,15 @@ miru uninstall # remove miru config
70
70
  | Visual Studio | `%USERPROFILE%\.mcp.json` | — | `~/.copilot/hooks/miru-search.json` |
71
71
  | Windsurf | — | — | `~/.codeium/windsurf/hooks.json` |
72
72
 
73
- Sub-agent files are also written where supported (see `miru install` plan). Windsurf hooks only — no MCP entry yet.
73
+ Sub-agent files are also written where supported (see `miru install` plan). Windsurf hooks only *(experimental)* — no MCP entry yet.
74
74
 
75
- ### Search hooks
75
+ ### Search hooks *(experimental)*
76
+
77
+ Search hooks are **experimental** — behavior and IDE support may change between releases.
76
78
 
77
79
  Hooks run `miru hook-guard` before built-in search tools execute. They **block** conceptual Grep/Glob/SemanticSearch and shell `rg`/`grep`/`find`, and tell the agent to use Miru MCP `search` instead. Exact literal lookups (e.g. `REDIS_HOST`, a symbol name) still pass through.
78
80
 
79
- Hooks are optional at install time but **on by default**. Disable them in the installer if you only want MCP + instructions.
81
+ Hooks are optional at install time but **on by default**. Disable them in the installer if you only want MCP + instructions, or while evaluating the experimental hook integration.
80
82
 
81
83
  **Team sub-agent in a repo** (optional):
82
84
 
@@ -88,6 +90,7 @@ miru init --agent claude --force
88
90
 
89
91
  ```bash
90
92
  miru search "auth middleware" ./src
93
+ miru expand src/auth.ts 42 ./src
91
94
  miru find-related src/auth.ts 42 ./src
92
95
  ```
93
96
 
@@ -101,14 +104,53 @@ bunx @takara-ai/miru-code search "auth middleware" ./src
101
104
 
102
105
  ## MCP tools
103
106
 
104
- When wired via `miru install`, your agent gets:
107
+ When wired via `miru install`, the MCP server exposes three tools. Pass the **project root** as `repo` for local workspaces (or an `https://` git URL). The index is built on the first call and cached for the session.
108
+
109
+ | Tool | When to use |
110
+ |------|-------------|
111
+ | `search` | Default for code exploration — hybrid semantic + keyword search. One call per question. |
112
+ | `expand` | More context in the **same file** when a hit has `truncated: true`. |
113
+ | `find_related` | Similar code in **other files** from a `file_path` + `anchor_line`. |
114
+
115
+ ### Workflow
116
+
117
+ 1. **`search`** with `query` + `repo` — returns compact snippets (~±15 lines) and relevance scores.
118
+ 2. If a hit has **`truncated: true`**, call **`expand`** with `file_path`, `anchor_line`, and `repo` — not another search or a full-file read.
119
+ 3. To trace similar patterns elsewhere, call **`find_related`** with the same `file_path`, `anchor_line`, and `repo`.
120
+ 4. Use your editor's **Read** on `absolute_path` only when editing or when `expand` still lacks context.
121
+
122
+ Prefer these tools over Grep, Glob, or SemanticSearch when Miru MCP is connected — hooks and instructions enforce that when enabled.
123
+
124
+ ### Parameters
125
+
126
+ **`search`**
127
+
128
+ | Param | Required | Notes |
129
+ |-------|----------|-------|
130
+ | `query` | yes | Natural language or code query |
131
+ | `repo` | yes | Project root or git URL |
132
+ | `top_k` | no | Results to return (default 3, max 10) |
133
+ | `dedupe_by_file` | no | Keep best hit per file (default `true`) |
134
+
135
+ **`expand`**
105
136
 
106
- | Tool | What it does |
107
- |------|----------------|
108
- | `search` | Query by meaning; pass `repo` (project root or `https://` git URL) |
109
- | `find_related` | Similar code at a `file_path` + line from a search hit |
137
+ | Param | Required | Notes |
138
+ |-------|----------|-------|
139
+ | `file_path` | yes | From hit `file_path` or `absolute_path` (local repos) |
140
+ | `anchor_line` | yes | From the search hit (`anchor_line` when truncated, else `start_line`) |
141
+ | `repo` | yes | Same repo as the search |
142
+ | `before` / `after` | no | Extra chunks before/after anchor (default 1 each) |
110
143
 
111
- Pass the **project root** as `repo` for local projects. Prefer these over Grep, Glob, or SemanticSearch when Miru MCP is connected — hooks and instructions enforce that when enabled.
144
+ **`find_related`**
145
+
146
+ | Param | Required | Notes |
147
+ |-------|----------|-------|
148
+ | `file_path` | yes | From a search hit |
149
+ | `anchor_line` | yes | From the search hit |
150
+ | `repo` | yes | Same repo as the search |
151
+ | `top_k` | no | Related chunks to return (default 3, max 10) |
152
+
153
+ Local repo hits include `absolute_path` for one-click navigation. Confirm exact literals (env var names, quoted strings) with native grep when needed.
112
154
 
113
155
  ## How it works
114
156
 
@@ -166,7 +208,7 @@ Set `MIRU_AST_CHUNKING=0` to disable AST and use structural → lines only.
166
208
  | `miru find-related <file> <line> [path]` | Related chunks |
167
209
  | `miru init --agent <id>` | Project-local sub-agent |
168
210
  | `miru clear [path]` | Drop index cache |
169
- | `miru hook-guard` | PreToolUse hook entrypoint (used by installers; reads JSON stdin) |
211
+ | `miru hook-guard` | PreToolUse hook entrypoint *(experimental)*; used by installers, reads JSON stdin |
170
212
  | `miru` | Start MCP server |
171
213
 
172
214
  ## Library
@@ -194,6 +236,7 @@ const results = await index.search({ query: "BM25 tokenize", topK: 10 });
194
236
  | `MIRU_ALLOW_HTTP_GIT` | Set `1` to allow plain `http://` git clones |
195
237
  | `MIRU_MCP_WATCH` | Set `0` to disable MCP file watch |
196
238
  | `MIRU_AST_CHUNKING` | Set `0` to disable tree-sitter AST chunking |
239
+ | `MIRU_QUIET` | Set `1` to skip the framed CLI banner (subtitle only on color terminals) |
197
240
  | `NO_COLOR` | Disable CLI colors |
198
241
 
199
242
  See `.env.example` for more.
@@ -223,6 +266,18 @@ bun test && bun run typecheck
223
266
 
224
267
  Local MCP: `"command": "bun", "args": ["/path/to/miru-code/src/cli.ts"]`
225
268
 
269
+ ### CLI banner
270
+
271
+ `miru help` and setup print a framed wordmark and Takara crane on color terminals. Set `MIRU_QUIET=1` to use the subtitle line only.
272
+
273
+ Crane ASCII art is committed in `src/brand-banner.ts`. To regenerate it from the SVG source (local `assets/red_crane_vector.svg`, gitignored):
274
+
275
+ ```bash
276
+ bun run scripts/render-crane-art.ts
277
+ ```
278
+
279
+ Copy the printed `TAKARA_CRANE` constant into `src/brand-banner.ts`. Requires ImageMagick (`magick`).
280
+
226
281
  ## License
227
282
 
228
283
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takara-ai/miru-code",
3
- "version": "1.0.5",
3
+ "version": "1.1.1",
4
4
  "description": "Miru (見る) — hybrid code search for agents. Bun, TypeScript, Takara embeddings.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agents.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { mkdir, readFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { join } from "node:path";
3
2
  import { buildSubagentBody, type NativeToolNames } from "./installer/search-policy.ts";
4
3
 
5
4
  export type AgentId = "claude" | "copilot" | "cursor" | "gemini" | "kiro" | "opencode";
@@ -45,7 +44,7 @@ export function agentDestination(agent: AgentId): string {
45
44
  }
46
45
 
47
46
  export async function loadAgentTemplate(agent: AgentId): Promise<string> {
48
- const frontmatter = (await readFile(join(AGENTS_DIR, `${agent}.md`), "utf-8")).trim();
47
+ const frontmatter = (await Bun.file(join(AGENTS_DIR, `${agent}.md`)).text()).trim();
49
48
  const body = buildSubagentBody(AGENT_NATIVE_TOOLS[agent]);
50
49
  return `${frontmatter}\n\n${body}\n`;
51
50
  }
@@ -59,7 +58,6 @@ export async function writeAgentFile(
59
58
  if (existing && !options.force) {
60
59
  throw new Error(`${dest} already exists. Run with --force to overwrite.`);
61
60
  }
62
- await mkdir(dirname(dest), { recursive: true });
63
61
  const content = await loadAgentTemplate(agent);
64
62
  await Bun.write(dest, content);
65
63
  return dest;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Framed CLI banner: wordmark + Takara crane art + subtitle.
3
+ * Shown on interactive help/setup when the output stream is a color TTY.
4
+ * Regenerate crane rows with: bun run scripts/render-crane-art.ts
5
+ */
6
+ import packageJson from "../package.json";
7
+ import {
8
+ brandColor,
9
+ colorEnabled,
10
+ dim,
11
+ displayWidth,
12
+ hexToRgb,
13
+ padLineToWidth,
14
+ } from "./terminal.ts";
15
+
16
+ const BRAND_SUBTITLE = "hybrid code search for agents by takara.ai";
17
+ const BRAND_INDENT = " ";
18
+ const BRAND_WELCOME = "Welcome to Miru";
19
+ const COLUMN_GAP = 4;
20
+ const BANNER_MIN_COLUMNS = 72; // below this width, print subtitle only (compact mode)
21
+
22
+ // Takara brand palette — hex is source of truth; RGB derived for ANSI SGR.
23
+ const BRAND_HEX = {
24
+ grey: "#4a4d4e",
25
+ red: "#d91009",
26
+ } as const;
27
+
28
+ const BRAND_RGB = {
29
+ grey: hexToRgb(BRAND_HEX.grey),
30
+ red: hexToRgb(BRAND_HEX.red),
31
+ } as const satisfies Record<string, readonly [number, number, number]>;
32
+
33
+ const MIRU_WORDMARK = [
34
+ "███╗ ███╗ ██╗ ██████╗ ██╗ ██╗",
35
+ "████╗ ████║ ██║ ██╔══██╗ ██║ ██║",
36
+ "██╔████╔██║ ██║ ██████╔╝ ██║ ██║",
37
+ "██║╚██╔╝██║ ██║ ██╔══██╗ ██║ ██║",
38
+ "██║ ╚═╝ ██║ ██║ ██║ ██║ ╚██████╔╝",
39
+ "╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ",
40
+ ] as const;
41
+
42
+ // Padded rows preserve crane silhouette when merged beside the wordmark.
43
+ // Do not crop per-row leading spaces — that breaks the half-block shape.
44
+ const TAKARA_CRANE = [
45
+ "█▄ ",
46
+ " ▀█▄ ▄▄██ ",
47
+ " ▀██ ▄████▀ ",
48
+ " ▀██▄ ▄███████ ",
49
+ " ███████████ ▄██▄ ",
50
+ " ▄█████████▄██▀▀█▄",
51
+ " ▄████████████▀ ▀",
52
+ " ███████████▀ ",
53
+ " ███████▀ ▀ ",
54
+ " ▄████▀ ",
55
+ " ██▀ ",
56
+ ];
57
+
58
+ export function isQuietBrand(): boolean {
59
+ // MIRU_QUIET=1 skips the framed banner on narrow or scripted terminals.
60
+ const value = process.env.MIRU_QUIET?.trim().toLowerCase();
61
+ return value === "1" || value === "true" || value === "yes";
62
+ }
63
+
64
+ function colorCell(
65
+ plain: string,
66
+ width: number,
67
+ rgb: readonly [number, number, number],
68
+ stream: NodeJS.WriteStream,
69
+ ): string {
70
+ if (!plain) {
71
+ return " ".repeat(width);
72
+ }
73
+ return padLineToWidth(brandColor(plain, rgb, stream), width);
74
+ }
75
+
76
+ function mergeArt(
77
+ stream: NodeJS.WriteStream,
78
+ leftWidth: number,
79
+ rightWidth: number,
80
+ wordmarkStart: number,
81
+ ): string[] {
82
+ // Crane is taller than the wordmark; offset wordmark rows so both are vertically centered.
83
+ const height = Math.max(wordmarkStart + MIRU_WORDMARK.length, TAKARA_CRANE.length);
84
+ const gap = " ".repeat(COLUMN_GAP);
85
+ const rows: string[] = [];
86
+
87
+ for (let i = 0; i < height; i++) {
88
+ const wordmark =
89
+ i >= wordmarkStart && i < wordmarkStart + MIRU_WORDMARK.length
90
+ ? (MIRU_WORDMARK[i - wordmarkStart] ?? "")
91
+ : "";
92
+ const crane = i < TAKARA_CRANE.length ? (TAKARA_CRANE[i] ?? "") : "";
93
+ rows.push(
94
+ colorCell(wordmark, leftWidth, BRAND_RGB.grey, stream) +
95
+ gap +
96
+ colorCell(crane, rightWidth, BRAND_RGB.red, stream),
97
+ );
98
+ }
99
+
100
+ return rows;
101
+ }
102
+
103
+ function frameLine(prefix: string, suffix: string, frameWidth: number): string {
104
+ const pad = Math.max(0, frameWidth - displayWidth(prefix) - displayWidth(suffix));
105
+ return `${prefix}${" ".repeat(pad)}${suffix}`;
106
+ }
107
+
108
+ export function formatBrandBannerLines(stream: NodeJS.WriteStream = process.stdout): string[] {
109
+ const versionLabel = `Version ${packageJson.version}`;
110
+ const leftWidth = Math.max(...MIRU_WORDMARK.map(displayWidth));
111
+ const rightWidth = Math.max(...TAKARA_CRANE.map(displayWidth));
112
+ // Frame must fit art and corner labels (welcome top-left, version bottom-right).
113
+ const frameWidth = Math.max(
114
+ leftWidth + COLUMN_GAP + rightWidth,
115
+ displayWidth(`┌ ${BRAND_WELCOME}`) + 1,
116
+ displayWidth(`└ ${versionLabel} ┘`),
117
+ );
118
+ const wordmarkStart = Math.max(0, Math.floor((TAKARA_CRANE.length - MIRU_WORDMARK.length) / 2));
119
+
120
+ const lines = [
121
+ frameLine(`┌ ${dim(BRAND_WELCOME, stream)}`, "┐", frameWidth),
122
+ ...mergeArt(stream, leftWidth, rightWidth, wordmarkStart).map((line) =>
123
+ padLineToWidth(line, frameWidth),
124
+ ),
125
+ frameLine("└", `${dim(versionLabel, stream)} ┘`, frameWidth),
126
+ "",
127
+ padLineToWidth(dim(BRAND_SUBTITLE, stream), frameWidth),
128
+ ];
129
+
130
+ return lines.map((line) => (line.length > 0 ? `${BRAND_INDENT}${line}` : line));
131
+ }
132
+
133
+ export function printBrandBanner(
134
+ stream: NodeJS.WriteStream = process.stdout,
135
+ compact = false,
136
+ ): void {
137
+ // No ANSI on pipes, CI logs, or when NO_COLOR is set.
138
+ if (!colorEnabled(stream)) {
139
+ return;
140
+ }
141
+
142
+ const useCompact = compact || (stream.columns ?? 80) < BANNER_MIN_COLUMNS || isQuietBrand();
143
+ if (useCompact) {
144
+ stream.write(`${dim(BRAND_SUBTITLE, stream)}\n`);
145
+ return;
146
+ }
147
+
148
+ for (const line of formatBrandBannerLines(stream)) {
149
+ stream.write(`${line}\n`);
150
+ }
151
+ }
package/src/cache.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { createHash } from "node:crypto";
1
+ /**
2
+ * On-disk index cache: location resolution, cache-key hashing, validation, and load.
3
+ *
4
+ * Each indexed source (local directory path or git URL + ref) maps to a SHA-256
5
+ * subdirectory under the Miru cache folder. `MiruIndex.fromPath` / `fromGit` call
6
+ * `getValidatedCache` before rebuilding; on hit they hydrate via `loadCachedIndex`.
7
+ */
8
+
2
9
  import { rm } from "node:fs/promises";
3
10
  import { join } from "node:path";
4
11
  import { resolveEmbeddingDimensions, resolveEmbeddingModel } from "./embeddings/openai.ts";
@@ -15,6 +22,7 @@ import { chunkFromDict } from "./types.ts";
15
22
  import { computeSourceCacheKey } from "./utils.ts";
16
23
  import { indexCacheEpoch } from "./version.ts";
17
24
 
25
+ /** Platform cache root; override with `MIRU_CACHE_HOME`. */
18
26
  export function resolveCacheFolder(): string {
19
27
  const override = process.env.MIRU_CACHE_HOME?.trim();
20
28
  if (override) {
@@ -36,12 +44,21 @@ export function resolveCacheFolder(): string {
36
44
  return base;
37
45
  }
38
46
 
47
+ /**
48
+ * Path to the saved index bundle for a source.
49
+ *
50
+ * `path` is a resolved local directory or a git cache key (`url` or `url@ref`).
51
+ * The on-disk folder name is SHA-256 of that key so paths stay short and safe.
52
+ */
39
53
  export function findIndexCachePath(path: string, ref?: string | null): string {
40
54
  const normalized = computeSourceCacheKey(path, ref);
41
- const subdir = createHash("sha256").update(normalized, "utf-8").digest("hex");
55
+ const hasher = new Bun.CryptoHasher("sha256");
56
+ hasher.update(normalized);
57
+ const subdir = hasher.digest("hex");
42
58
  return join(resolveCacheFolder(), subdir, "index");
43
59
  }
44
60
 
61
+ /** True when saved metadata still matches the requested index configuration. */
45
62
  function metadataMatches(
46
63
  metadata: Record<string, unknown>,
47
64
  embeddingModel: string,
@@ -80,6 +97,15 @@ function metadataMatches(
80
97
  }
81
98
  }
82
99
 
100
+ /**
101
+ * Return a cache directory path when a complete, compatible bundle exists; otherwise `null`.
102
+ *
103
+ * Checks persisted files, metadata (epoch, model, content types, vector storage),
104
+ * semantic index shape, and — for local indexes — that `root_path` and listed
105
+ * `file_paths` still exist (needed for incremental `applyFileChanges`).
106
+ *
107
+ * Stale `index_epoch` entries are deleted; other mismatches are left on disk.
108
+ */
83
109
  export async function getValidatedCache(
84
110
  path: string,
85
111
  embeddingModel: string | undefined,
@@ -132,6 +158,7 @@ export async function getValidatedCache(
132
158
  return indexPath;
133
159
  }
134
160
 
161
+ /** Load BM25, semantic vectors, chunks, and metadata from a validated cache path. */
135
162
  export async function loadCachedIndex(indexPath: string): Promise<{
136
163
  bm25: Awaited<ReturnType<typeof loadBm25>>;
137
164
  semantic: Awaited<ReturnType<typeof loadSemantic>>;
@@ -147,6 +174,7 @@ export async function loadCachedIndex(indexPath: string): Promise<{
147
174
  return { bm25, semantic, chunks, metadata };
148
175
  }
149
176
 
177
+ /** Remove the cached index bundle for a source (same key as `findIndexCachePath`). */
150
178
  export async function clearCache(path: string): Promise<void> {
151
179
  const indexPath = findIndexCachePath(path);
152
180
  await rm(indexPath, { recursive: true, force: true });
package/src/cli-ui.ts CHANGED
@@ -1,66 +1,26 @@
1
+ import { printBrandBanner } from "./brand-banner.ts";
2
+ import { bold, colorEnabled, cyan, dim, green, magenta, red, yellow } from "./terminal.ts";
1
3
  import type { SearchResult } from "./types.ts";
2
4
  import { formatRelevanceScore } from "./utils.ts";
3
5
 
4
- const ANSI = {
5
- reset: "\x1b[0m",
6
- bold: "\x1b[1m",
7
- dim: "\x1b[2m",
8
- cyan: "\x1b[36m",
9
- green: "\x1b[32m",
10
- red: "\x1b[31m",
11
- yellow: "\x1b[33m",
12
- magenta: "\x1b[35m",
13
- } as const;
14
-
15
- function colorEnabled(stream: NodeJS.WriteStream = process.stdout): boolean {
16
- return stream.isTTY && process.env.NO_COLOR === undefined;
17
- }
18
-
19
- function paint(text: string, code: string, stream?: NodeJS.WriteStream): string {
20
- if (!colorEnabled(stream)) {
21
- return text;
22
- }
23
- return `${code}${text}${ANSI.reset}`;
24
- }
25
-
26
- export function bold(text: string, stream?: NodeJS.WriteStream): string {
27
- return paint(text, ANSI.bold, stream);
28
- }
29
-
30
- export function dim(text: string, stream?: NodeJS.WriteStream): string {
31
- return paint(text, ANSI.dim, stream);
32
- }
33
-
34
- export function cyan(text: string, stream?: NodeJS.WriteStream): string {
35
- return paint(text, ANSI.cyan, stream);
36
- }
37
-
38
- export function green(text: string, stream?: NodeJS.WriteStream): string {
39
- return paint(text, ANSI.green, stream);
40
- }
41
-
42
- export function red(text: string, stream?: NodeJS.WriteStream): string {
43
- return paint(text, ANSI.red, stream);
44
- }
45
-
46
- export function yellow(text: string, stream?: NodeJS.WriteStream): string {
47
- return paint(text, ANSI.yellow, stream);
48
- }
49
-
50
- export function magenta(text: string, stream?: NodeJS.WriteStream): string {
51
- return paint(text, ANSI.magenta, stream);
52
- }
53
-
54
- export function isInteractiveStdout(): boolean {
55
- return process.stdout.isTTY && process.env.NO_COLOR === undefined;
56
- }
6
+ export { formatBrandBannerLines, isQuietBrand, printBrandBanner } from "./brand-banner.ts";
7
+ // Re-export terminal + banner APIs for existing cli-ui consumers.
8
+ export {
9
+ bold,
10
+ cyan,
11
+ dim,
12
+ green,
13
+ magenta,
14
+ red,
15
+ yellow,
16
+ } from "./terminal.ts";
57
17
 
58
18
  export function prefersJsonOutput(jsonFlag: boolean): boolean {
59
19
  return jsonFlag || !process.stdout.isTTY;
60
20
  }
61
21
 
62
22
  export function brandTitle(): string {
63
- return bold("Miru", process.stderr) + dim(" (見る)", process.stderr);
23
+ return bold("MIRU", process.stderr) + dim(" (見る)", process.stderr);
64
24
  }
65
25
 
66
26
  export function writeStdout(line = ""): void {
@@ -72,15 +32,33 @@ export function writeStderr(line = ""): void {
72
32
  }
73
33
 
74
34
  export function divider(char = "─", width = 52, stream: NodeJS.WriteStream = process.stdout): void {
75
- const line = char.repeat(width);
76
- stream.write(`${dim(line, stream)}\n`);
35
+ stream.write(`${dim(char.repeat(width), stream)}\n`);
36
+ }
37
+
38
+ function showBanner(stream: NodeJS.WriteStream = process.stdout): boolean {
39
+ // Full banner replaces the plain "MIRU · title" header on color TTYs.
40
+ if (!colorEnabled(stream)) {
41
+ return false;
42
+ }
43
+ printBrandBanner(stream);
44
+ return true;
45
+ }
46
+
47
+ export function header(): void {
48
+ writeStdout("");
49
+ if (!showBanner()) {
50
+ writeStdout(brandTitle());
51
+ }
52
+ divider();
77
53
  }
78
54
 
79
- export function header(title: string, subtitle?: string): void {
55
+ export function commandHeader(name: string, summary: string): void {
80
56
  writeStdout("");
81
- writeStdout(brandTitle() + (title ? dim(` · ${title}`) : ""));
82
- if (subtitle) {
83
- writeStdout(dim(subtitle));
57
+ if (!showBanner()) {
58
+ writeStdout(`${brandTitle()} ${name}`);
59
+ writeStdout(summary);
60
+ } else {
61
+ writeStdout(summary);
84
62
  }
85
63
  divider();
86
64
  }
@@ -176,7 +154,8 @@ export function formatSearchResultsPretty(query: string, results: SearchResult[]
176
154
  }
177
155
  }
178
156
 
179
- if (isInteractiveStdout()) {
157
+ if (colorEnabled(process.stdout)) {
158
+ // Only nudge interactive users; JSON output should stay machine-clean.
180
159
  lines.push("");
181
160
  lines.push(dim("Tip: add --json for machine-readable output"));
182
161
  }
package/src/cli.ts CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  } from "./utils.ts";
45
45
  import { maybeNotifyUpdate, miruVersion } from "./version.ts";
46
46
 
47
- loadEnvFiles();
47
+ await loadEnvFiles();
48
48
  normalizeTakaraApiKeyEnv();
49
49
  await loadStoredCredentials();
50
50
 
@@ -158,7 +158,7 @@ async function runSearch(
158
158
 
159
159
  const index = await withSpinner("Indexing and searching", async () => {
160
160
  const built = await MiruIndex.fromSource(path, content);
161
- await built.saveToDefaultCache(path);
161
+ await built.saveToCache(path);
162
162
  const results = await built.search({ query, topK });
163
163
  return { index: built, results };
164
164
  });
@@ -188,7 +188,7 @@ async function runExpand(
188
188
  before,
189
189
  after,
190
190
  );
191
- await built.saveToDefaultCache(path);
191
+ await built.saveToCache(path);
192
192
  return formatExpandResults(filePath, line, anchor, expanded, {
193
193
  repoRoot,
194
194
  before,
@@ -231,7 +231,7 @@ async function runFindRelated(
231
231
  throw new RelatedChunkNotFoundError(filePath, line);
232
232
  }
233
233
  const hits = await built.findRelated(chunk, topK);
234
- await built.saveToDefaultCache(path);
234
+ await built.saveToCache(path);
235
235
  return {
236
236
  results: hits,
237
237
  label: formatRelatedHeader(filePath, line),
@@ -1,15 +1,25 @@
1
- import { availableParallelism, cpus } from "node:os";
1
+ /**
2
+ * Concurrency helpers for indexing and embedding pipelines.
3
+ *
4
+ * Caps parallel file parsing, BM25 scoring, and embed batching so Miru stays
5
+ * responsive on laptops and shared CI runners without starving the OS.
6
+ */
7
+ import { availableParallelism } from "node:os";
2
8
 
3
- const DEFAULT_RESERVE_CORES = 2;
9
+ /** Cores left for the event loop, MCP stdio, and other host processes during indexing. */
10
+ export const DEFAULT_RESERVE_CORES = 2;
4
11
 
5
- export function resolveCpuCount(): number {
6
- if (typeof availableParallelism === "function") {
7
- return availableParallelism();
8
- }
9
- return cpus().length;
12
+ function workerCount(concurrency: number, itemCount: number): number {
13
+ return Math.max(1, Math.min(concurrency, itemCount));
10
14
  }
11
15
 
12
- /** Worker pool size: logical CPUs minus 2, minimum 1. Override with MIRU_CONCURRENCY. */
16
+ /**
17
+ * Worker pool size for file parsing and other CPU-bound index work.
18
+ *
19
+ * Default: `availableParallelism() - DEFAULT_RESERVE_CORES`, floored at 1.
20
+ * Override with `MIRU_CONCURRENCY` or legacy alias `MIRU_WORKERS`.
21
+ * Invalid env values are ignored so a typo does not crash indexing.
22
+ */
13
23
  export function resolveWorkerConcurrency(): number {
14
24
  const raw = process.env.MIRU_CONCURRENCY ?? process.env.MIRU_WORKERS;
15
25
  if (raw) {
@@ -18,37 +28,34 @@ export function resolveWorkerConcurrency(): number {
18
28
  return Math.floor(n);
19
29
  }
20
30
  }
21
- return Math.max(1, resolveCpuCount() - DEFAULT_RESERVE_CORES);
31
+ return Math.max(1, availableParallelism() - DEFAULT_RESERVE_CORES);
22
32
  }
23
33
 
24
- /** Run async work over items with at most `concurrency` tasks in flight. Results keep input order. */
34
+ /**
35
+ * Bounded parallel map: at most `concurrency` workers run `worker` at once.
36
+ *
37
+ * Unlike `Promise.all(items.map(worker))`, this limits in-flight tasks when
38
+ * `items` is large (e.g. thousands of source files). Output order matches
39
+ * input order because each result is stored at its original index.
40
+ *
41
+ * Workers share a single `items.entries()` iterator; each claims the next
42
+ * `[index, item]` pair until the input is exhausted.
43
+ */
25
44
  export async function mapPool<T, R>(
26
45
  items: readonly T[],
27
46
  concurrency: number,
28
47
  worker: (item: T, index: number) => Promise<R>,
29
48
  ): Promise<R[]> {
30
- if (items.length === 0) {
31
- return [];
32
- }
33
-
34
- const limit = Math.max(1, Math.min(concurrency, items.length));
35
49
  const results = new Array<R>(items.length);
36
- let nextIndex = 0;
50
+ const pending = items.entries();
37
51
 
38
- async function drain(): Promise<void> {
39
- while (true) {
40
- const i = nextIndex++;
41
- if (i >= items.length) {
42
- return;
52
+ await Promise.all(
53
+ Array.from({ length: workerCount(concurrency, items.length) }, async () => {
54
+ for (const [i, item] of pending) {
55
+ results[i] = await worker(item, i);
43
56
  }
44
- const item = items[i];
45
- if (item === undefined) {
46
- continue;
47
- }
48
- results[i] = await worker(item, i);
49
- }
50
- }
57
+ }),
58
+ );
51
59
 
52
- await Promise.all(Array.from({ length: limit }, () => drain()));
53
60
  return results;
54
61
  }