@takara-ai/miru-code 1.0.4 → 1.1.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.
- package/README.md +68 -13
- package/package.json +1 -1
- package/src/agents/copilot.md +1 -1
- package/src/agents.ts +2 -4
- package/src/brand-banner.ts +151 -0
- package/src/cache.ts +30 -2
- package/src/cli-ui.ts +40 -61
- package/src/cli.ts +23 -6
- package/src/concurrency.ts +36 -29
- package/src/credentials.ts +4 -7
- package/src/embeddings/openai.ts +10 -7
- package/src/env-files.ts +10 -7
- package/src/help.ts +18 -22
- package/src/index/chunk-selector.ts +57 -0
- package/src/index/dense.ts +75 -54
- package/src/index/entry-chunks.ts +1 -1
- package/src/index/int8-dot.ts +55 -0
- package/src/index/persistence.ts +0 -2
- package/src/index/quantize.ts +52 -64
- package/src/index/semantic-index.ts +1 -1
- package/src/index/sparse.ts +1 -1
- package/src/index/topk.ts +78 -1
- package/src/index/vector-storage.ts +6 -1
- package/src/index.ts +23 -1
- package/src/installer/config.ts +8 -12
- package/src/installer/hooks/install.ts +6 -9
- package/src/installer/installer.ts +3 -6
- package/src/installer/search-policy.ts +14 -11
- package/src/mcp/index-cache.ts +2 -2
- package/src/mcp/server.ts +18 -18
- package/src/miru-index.ts +116 -19
- package/src/search.ts +4 -4
- package/src/setup.ts +18 -7
- package/src/terminal.ts +171 -0
- package/src/version.ts +21 -10
package/src/mcp/server.ts
CHANGED
|
@@ -42,7 +42,11 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
|
|
|
42
42
|
{
|
|
43
43
|
description: `${MCP_SEARCH_TOOL_DESCRIPTION} Indexes \`repo\` on first call; later calls reuse the session cache.`,
|
|
44
44
|
inputSchema: {
|
|
45
|
-
query: z
|
|
45
|
+
query: z
|
|
46
|
+
.string()
|
|
47
|
+
.describe(
|
|
48
|
+
"Natural language or code query — your default for all code search in this repo.",
|
|
49
|
+
),
|
|
46
50
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
47
51
|
top_k: z
|
|
48
52
|
.number()
|
|
@@ -83,12 +87,10 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
|
|
|
83
87
|
file_path: z
|
|
84
88
|
.string()
|
|
85
89
|
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
86
|
-
|
|
90
|
+
anchor_line: z
|
|
87
91
|
.number()
|
|
88
92
|
.int()
|
|
89
|
-
.describe(
|
|
90
|
-
"Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
|
|
91
|
-
),
|
|
93
|
+
.describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
|
|
92
94
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
93
95
|
before: z
|
|
94
96
|
.number()
|
|
@@ -104,7 +106,7 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
|
|
|
104
106
|
.describe("Extra chunks after the anchor (default 1)."),
|
|
105
107
|
},
|
|
106
108
|
},
|
|
107
|
-
async ({ file_path: filePath,
|
|
109
|
+
async ({ file_path: filePath, anchor_line: anchorLine, repo, before, after }) => {
|
|
108
110
|
try {
|
|
109
111
|
const index = await getIndexForRepo(repo, cache);
|
|
110
112
|
const repoRoot = localRepoRoot(repo);
|
|
@@ -113,19 +115,19 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
|
|
|
113
115
|
const { anchor, chunks: expanded } = expandChunksAtLine(
|
|
114
116
|
index.chunks,
|
|
115
117
|
filePath,
|
|
116
|
-
|
|
118
|
+
anchorLine,
|
|
117
119
|
repoRoot,
|
|
118
120
|
beforeCount,
|
|
119
121
|
afterCount,
|
|
120
122
|
);
|
|
121
123
|
if (!anchor) {
|
|
122
124
|
return toolText(
|
|
123
|
-
`No chunk found at ${filePath}:${
|
|
125
|
+
`No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
|
|
124
126
|
);
|
|
125
127
|
}
|
|
126
128
|
return toolText(
|
|
127
129
|
JSON.stringify(
|
|
128
|
-
formatExpandResults(filePath,
|
|
130
|
+
formatExpandResults(filePath, anchorLine, anchor, expanded, {
|
|
129
131
|
repoRoot,
|
|
130
132
|
before: beforeCount,
|
|
131
133
|
after: afterCount,
|
|
@@ -146,12 +148,10 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
|
|
|
146
148
|
file_path: z
|
|
147
149
|
.string()
|
|
148
150
|
.describe("Path from a search hit (`file_path` or `absolute_path` for local repos)."),
|
|
149
|
-
|
|
151
|
+
anchor_line: z
|
|
150
152
|
.number()
|
|
151
153
|
.int()
|
|
152
|
-
.describe(
|
|
153
|
-
"Line from the hit: `anchor_line` when truncated, otherwise `start_line` (1-indexed).",
|
|
154
|
-
),
|
|
154
|
+
.describe("Line from the search hit (`anchor_line` when truncated, else `start_line`)."),
|
|
155
155
|
repo: z.string().describe(REPO_DESCRIPTION),
|
|
156
156
|
top_k: z
|
|
157
157
|
.number()
|
|
@@ -164,25 +164,25 @@ export function createMcpServer(cache: IndexCache): MiruMcpServer {
|
|
|
164
164
|
),
|
|
165
165
|
},
|
|
166
166
|
},
|
|
167
|
-
async ({ file_path: filePath,
|
|
167
|
+
async ({ file_path: filePath, anchor_line: anchorLine, repo, top_k: topK }) => {
|
|
168
168
|
try {
|
|
169
169
|
const index = await getIndexForRepo(repo, cache);
|
|
170
170
|
const repoRoot = localRepoRoot(repo);
|
|
171
|
-
const chunk = resolveChunk(index.chunks, filePath,
|
|
171
|
+
const chunk = resolveChunk(index.chunks, filePath, anchorLine, repoRoot);
|
|
172
172
|
if (!chunk) {
|
|
173
173
|
return toolText(
|
|
174
|
-
`No chunk found at ${filePath}:${
|
|
174
|
+
`No chunk found at ${filePath}:${anchorLine}. Make sure the file is indexed and the line number is within a known chunk.`,
|
|
175
175
|
);
|
|
176
176
|
}
|
|
177
177
|
const results = await index.findRelated(chunk, clampMcpTopK(topK));
|
|
178
178
|
if (results.length === 0) {
|
|
179
179
|
return toolText(
|
|
180
|
-
JSON.stringify({ error: `No related chunks found for ${filePath}:${
|
|
180
|
+
JSON.stringify({ error: `No related chunks found for ${filePath}:${anchorLine}.` }),
|
|
181
181
|
);
|
|
182
182
|
}
|
|
183
183
|
return toolText(
|
|
184
184
|
JSON.stringify(
|
|
185
|
-
formatResults(`Chunks related to ${filePath}:${
|
|
185
|
+
formatResults(`Chunks related to ${filePath}:${anchorLine}`, results, {
|
|
186
186
|
repoRoot,
|
|
187
187
|
snippet: true,
|
|
188
188
|
}),
|
package/src/miru-index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./embeddings/openai.ts";
|
|
10
10
|
import { cloneGitRepository } from "./git.ts";
|
|
11
11
|
import type { BM25Index } from "./index/bm25.ts";
|
|
12
|
+
import { buildChunkSelector } from "./index/chunk-selector.ts";
|
|
12
13
|
import { createIndexFromPath } from "./index/create.ts";
|
|
13
14
|
import { applyIncrementalFileChanges } from "./index/incremental.ts";
|
|
14
15
|
import { persistencePaths, saveIndexBundle } from "./index/persistence.ts";
|
|
@@ -19,27 +20,71 @@ import { chunkKey, chunkToDict } from "./types.ts";
|
|
|
19
20
|
import { computeSourceCacheKey, isGitUrl } from "./utils.ts";
|
|
20
21
|
import { indexCacheEpoch } from "./version.ts";
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* In-memory search index over a codebase: BM25 keyword scores plus semantic
|
|
25
|
+
* embeddings, with optional disk cache for reuse across runs.
|
|
26
|
+
*
|
|
27
|
+
* Construct via `fromPath`, `fromGit`, or `fromSource`; load a saved bundle
|
|
28
|
+
* with `loadFromDisk`. Search combines both signals; `findRelated` is
|
|
29
|
+
* semantic-only similarity from an existing chunk or hit.
|
|
30
|
+
*
|
|
31
|
+
* Lifecycle:
|
|
32
|
+
* - **Build** — parse files, chunk, embed, score with BM25
|
|
33
|
+
* - **Cache** — `save` writes a bundle keyed by source path or git URL + ref
|
|
34
|
+
* - **Query** — `search` blends BM25 and vectors; filters use prebuilt mappings
|
|
35
|
+
* - **Update** — `applyFileChanges` re-indexes only touched paths (local roots)
|
|
36
|
+
*/
|
|
22
37
|
export class MiruIndex {
|
|
38
|
+
/** OpenAI (or configured) client used for query embeddings and reranking. */
|
|
23
39
|
readonly embeddings: EmbeddingBackend;
|
|
40
|
+
|
|
41
|
+
/** Flat list of all chunks; index position is the stable chunk id. */
|
|
24
42
|
private chunksInternal: Chunk[];
|
|
43
|
+
|
|
44
|
+
/** Inverted index for lexical (BM25) retrieval. */
|
|
25
45
|
private bm25Index: BM25Index;
|
|
46
|
+
|
|
47
|
+
/** Vector store for semantic nearest-neighbor search. */
|
|
26
48
|
private semanticIndex: SemanticIndex;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when hydrated from disk via `loadFromDisk`.
|
|
52
|
+
* Affects `saveToCache`, which skips re-saving already-cached indexes unless forced.
|
|
53
|
+
*/
|
|
27
54
|
private loadedFromDiskFlag: boolean;
|
|
28
55
|
|
|
56
|
+
/** Model id stored in cache metadata and used for new embeddings. */
|
|
29
57
|
readonly embeddingModel: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Absolute path to the indexed tree on disk, or `null` for git-only indexes
|
|
61
|
+
* (clone is deleted after indexing; incremental updates need a local root).
|
|
62
|
+
*/
|
|
30
63
|
private readonly root: string | null;
|
|
64
|
+
|
|
65
|
+
/** Content kinds indexed at build time (e.g. `code`, `markdown`). */
|
|
31
66
|
private readonly content: ContentType[];
|
|
67
|
+
|
|
68
|
+
/** Repo-relative file path → chunk indices in `chunksInternal`. */
|
|
32
69
|
private fileMapping: Map<string, number[]>;
|
|
70
|
+
|
|
71
|
+
/** Language tag → chunk indices, for `filterLanguages` in search. */
|
|
33
72
|
private languageMapping: Map<string, number[]>;
|
|
34
73
|
|
|
74
|
+
/** Read-only view of indexed chunks. */
|
|
35
75
|
get chunks(): Chunk[] {
|
|
36
76
|
return this.chunksInternal;
|
|
37
77
|
}
|
|
38
78
|
|
|
79
|
+
/** Whether this instance was loaded from a cache bundle rather than freshly built. */
|
|
39
80
|
get loadedFromDisk(): boolean {
|
|
40
81
|
return this.loadedFromDiskFlag;
|
|
41
82
|
}
|
|
42
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Prefer static factories (`fromPath`, `fromGit`, `loadFromDisk`) over calling
|
|
86
|
+
* directly; the constructor is public for tests and advanced composition.
|
|
87
|
+
*/
|
|
43
88
|
constructor(options: {
|
|
44
89
|
embeddings: EmbeddingBackend;
|
|
45
90
|
bm25Index: BM25Index;
|
|
@@ -63,6 +108,10 @@ export class MiruIndex {
|
|
|
63
108
|
this.rebuildMappings();
|
|
64
109
|
}
|
|
65
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Rebuild file/language → chunk-index maps after chunks change.
|
|
113
|
+
* Called from the constructor and after incremental updates.
|
|
114
|
+
*/
|
|
66
115
|
private rebuildMappings(): void {
|
|
67
116
|
this.fileMapping = new Map();
|
|
68
117
|
this.languageMapping = new Map();
|
|
@@ -85,6 +134,12 @@ export class MiruIndex {
|
|
|
85
134
|
}
|
|
86
135
|
}
|
|
87
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Clone a remote repo, index it, persist to cache, and return the in-memory index.
|
|
139
|
+
*
|
|
140
|
+
* Cache key is derived from URL + ref. On hit, skips clone and embedding work.
|
|
141
|
+
* The temporary clone directory is always removed in `finally`.
|
|
142
|
+
*/
|
|
88
143
|
static async fromGit(
|
|
89
144
|
url: string,
|
|
90
145
|
content: ContentType[] = ["code"],
|
|
@@ -113,6 +168,7 @@ export class MiruIndex {
|
|
|
113
168
|
semanticIndex: semantic,
|
|
114
169
|
chunks,
|
|
115
170
|
embeddingModel: model,
|
|
171
|
+
// No durable local root — clone is deleted; incremental updates unavailable.
|
|
116
172
|
root: null,
|
|
117
173
|
content,
|
|
118
174
|
});
|
|
@@ -123,6 +179,7 @@ export class MiruIndex {
|
|
|
123
179
|
}
|
|
124
180
|
}
|
|
125
181
|
|
|
182
|
+
/** Resolve a local path or git URL and delegate to `fromPath` or `fromGit`. */
|
|
126
183
|
static async fromSource(
|
|
127
184
|
source: string,
|
|
128
185
|
content: ContentType[] = ["code"],
|
|
@@ -135,6 +192,13 @@ export class MiruIndex {
|
|
|
135
192
|
return MiruIndex.fromPath(source, content, embeddingModel);
|
|
136
193
|
}
|
|
137
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Index a directory on disk, reusing a validated cache hit when available.
|
|
197
|
+
*
|
|
198
|
+
* `root` is set to the resolved path so `applyFileChanges` can re-chunk files
|
|
199
|
+
* later. Unlike `fromGit`, this does not auto-save to cache; callers use
|
|
200
|
+
* `saveToCache` when they want persistence.
|
|
201
|
+
*/
|
|
138
202
|
static async fromPath(
|
|
139
203
|
path: string,
|
|
140
204
|
content: ContentType[] = ["code"],
|
|
@@ -178,6 +242,12 @@ export class MiruIndex {
|
|
|
178
242
|
});
|
|
179
243
|
}
|
|
180
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Hydrate BM25, semantic vectors, and chunks from a saved index bundle.
|
|
247
|
+
*
|
|
248
|
+
* Validates embedding model compatibility via cache metadata. Sets
|
|
249
|
+
* `loadedFromDisk` so callers can avoid redundant cache writes.
|
|
250
|
+
*/
|
|
181
251
|
static async loadFromDisk(path: string, embeddingModel?: string): Promise<MiruIndex> {
|
|
182
252
|
const model = embeddingModel ?? resolveEmbeddingModel();
|
|
183
253
|
const { bm25, semantic, chunks, metadata } = await loadCachedIndex(path);
|
|
@@ -196,6 +266,12 @@ export class MiruIndex {
|
|
|
196
266
|
});
|
|
197
267
|
}
|
|
198
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Write BM25, semantic vectors, chunk metadata, and index metadata to `path`.
|
|
271
|
+
*
|
|
272
|
+
* Metadata includes `index_epoch` for invalidation when Miru's on-disk format
|
|
273
|
+
* changes, plus embedding model/dimensions for cache validation on reload.
|
|
274
|
+
*/
|
|
199
275
|
async save(path: string): Promise<void> {
|
|
200
276
|
const paths = persistencePaths(path);
|
|
201
277
|
await mkdir(paths.root, { recursive: true });
|
|
@@ -218,17 +294,24 @@ export class MiruIndex {
|
|
|
218
294
|
});
|
|
219
295
|
}
|
|
220
296
|
|
|
221
|
-
|
|
222
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Persist to the default cache location for `sourcePath`.
|
|
299
|
+
*
|
|
300
|
+
* By default this is a no-op for unchanged indexes loaded from disk, avoiding
|
|
301
|
+
* redundant cache writes. Pass `force: true` after mutating a cached index.
|
|
302
|
+
*/
|
|
303
|
+
async saveToCache(sourcePath: string, options: { force?: boolean } = {}): Promise<void> {
|
|
304
|
+
if (!this.loadedFromDiskFlag || options.force) {
|
|
223
305
|
await this.save(findIndexCachePath(sourcePath));
|
|
224
306
|
}
|
|
225
307
|
}
|
|
226
308
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Re-chunk and re-embed only the given repo-relative paths; drop their old chunks.
|
|
311
|
+
*
|
|
312
|
+
* Requires `root` (local `fromPath` indexes only). Updates BM25, semantic
|
|
313
|
+
* index, and mappings in place; clears `loadedFromDisk` since data changed.
|
|
314
|
+
*/
|
|
232
315
|
async applyFileChanges(relativePaths: readonly string[]): Promise<void> {
|
|
233
316
|
if (!this.root) {
|
|
234
317
|
throw new Error("Incremental update requires a local index with root_path set.");
|
|
@@ -253,20 +336,27 @@ export class MiruIndex {
|
|
|
253
336
|
this.rebuildMappings();
|
|
254
337
|
}
|
|
255
338
|
|
|
256
|
-
private getSelector(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
339
|
+
private getSelector(
|
|
340
|
+
filterLanguages?: string[],
|
|
341
|
+
filterPaths?: string[],
|
|
342
|
+
): readonly number[] | undefined {
|
|
343
|
+
return buildChunkSelector(
|
|
344
|
+
{
|
|
345
|
+
fileMapping: this.fileMapping,
|
|
346
|
+
languageMapping: this.languageMapping,
|
|
347
|
+
},
|
|
348
|
+
filterLanguages,
|
|
349
|
+
filterPaths,
|
|
350
|
+
);
|
|
268
351
|
}
|
|
269
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Hybrid BM25 + semantic search over indexed chunks.
|
|
355
|
+
*
|
|
356
|
+
* `alpha` blends keyword vs vector scores (default depends on content type).
|
|
357
|
+
* `filterLanguages` / `filterPaths` restrict candidates before ranking.
|
|
358
|
+
* `rerank` defaults to on for code indexes (cross-encoder style reranking).
|
|
359
|
+
*/
|
|
270
360
|
async search(options: {
|
|
271
361
|
query: string;
|
|
272
362
|
topK?: number;
|
|
@@ -296,6 +386,13 @@ export class MiruIndex {
|
|
|
296
386
|
});
|
|
297
387
|
}
|
|
298
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Semantic neighbors of a chunk or search hit, excluding the source itself.
|
|
391
|
+
*
|
|
392
|
+
* Uses the chunk's text as the query embedding. When the source has a
|
|
393
|
+
* language, search is scoped to same-language chunks. Requests `topK + 1`
|
|
394
|
+
* hits so filtering out the source still yields `topK` results.
|
|
395
|
+
*/
|
|
299
396
|
async findRelated(source: Chunk | SearchResult, topK = 5): Promise<SearchResult[]> {
|
|
300
397
|
const target = "chunk" in source ? source.chunk : source;
|
|
301
398
|
const selector = target.language ? this.getSelector([target.language]) : undefined;
|
package/src/search.ts
CHANGED
|
@@ -35,7 +35,7 @@ function semanticFromQueryVector(
|
|
|
35
35
|
semanticIndex: SemanticIndex,
|
|
36
36
|
chunks: Chunk[],
|
|
37
37
|
topK: number,
|
|
38
|
-
selector?: number[],
|
|
38
|
+
selector?: readonly number[],
|
|
39
39
|
): SearchResult[] {
|
|
40
40
|
const { indices, distances } = semanticIndex.query(queryVec, topK, selector);
|
|
41
41
|
return indices.flatMap((index, i) => {
|
|
@@ -52,7 +52,7 @@ async function searchBm25(
|
|
|
52
52
|
bm25Index: BM25Index,
|
|
53
53
|
chunks: Chunk[],
|
|
54
54
|
topK: number,
|
|
55
|
-
selector?: number[],
|
|
55
|
+
selector?: readonly number[],
|
|
56
56
|
): Promise<SearchResult[]> {
|
|
57
57
|
const tokens = tokenize(query);
|
|
58
58
|
if (tokens.length === 0) {
|
|
@@ -79,7 +79,7 @@ export async function hybridSearch(options: {
|
|
|
79
79
|
chunks: Chunk[];
|
|
80
80
|
topK: number;
|
|
81
81
|
alpha?: number | null;
|
|
82
|
-
selector?: number[];
|
|
82
|
+
selector?: readonly number[];
|
|
83
83
|
rerank?: boolean;
|
|
84
84
|
}): Promise<SearchResult[]> {
|
|
85
85
|
const {
|
|
@@ -170,7 +170,7 @@ export async function searchSemanticOnly(options: {
|
|
|
170
170
|
semanticIndex: SemanticIndex;
|
|
171
171
|
chunks: Chunk[];
|
|
172
172
|
topK: number;
|
|
173
|
-
selector?: number[];
|
|
173
|
+
selector?: readonly number[];
|
|
174
174
|
}): Promise<SearchResult[]> {
|
|
175
175
|
const queryVec = await options.embeddings.embedQuery(options.query);
|
|
176
176
|
return semanticFromQueryVector(
|
package/src/setup.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { divider, fail, hint, info, printBrandBanner, success, writeStderr } from "./cli-ui.ts";
|
|
2
2
|
import {
|
|
3
3
|
clearStoredCredentials,
|
|
4
4
|
loadStoredCredentials,
|
|
@@ -28,16 +28,21 @@ export interface RunSetupOptions {
|
|
|
28
28
|
skipValidation?: boolean;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export
|
|
31
|
+
export interface RunSetupResult {
|
|
32
|
+
path: string;
|
|
33
|
+
newlySaved: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runSetup(options: RunSetupOptions = {}): Promise<RunSetupResult> {
|
|
32
37
|
if (!options.force && hasTakaraApiKeyInEnv()) {
|
|
33
38
|
const path = resolveCredentialsPath();
|
|
34
39
|
const stored = await readStoredCredentials();
|
|
35
40
|
if (stored) {
|
|
36
41
|
info(`API key already configured (env + ${path}). Use --force to replace stored key.`);
|
|
37
|
-
return path;
|
|
42
|
+
return { path, newlySaved: false };
|
|
38
43
|
}
|
|
39
44
|
info("API key already set via environment variable. Stored credentials unchanged.");
|
|
40
|
-
return resolveCredentialsPath();
|
|
45
|
+
return { path: resolveCredentialsPath(), newlySaved: false };
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
if (!options.force) {
|
|
@@ -45,12 +50,13 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
|
|
|
45
50
|
if (stored && !options.apiKey) {
|
|
46
51
|
info(`API key already stored at ${resolveCredentialsPath()}. Use --force to replace.`);
|
|
47
52
|
process.env.TAKARA_API_KEY = stored.takara_api_key;
|
|
48
|
-
return resolveCredentialsPath();
|
|
53
|
+
return { path: resolveCredentialsPath(), newlySaved: false };
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
writeStderr("");
|
|
53
|
-
|
|
58
|
+
// stderr banner: setup runs before stdout may be a TTY (e.g. piped miru search).
|
|
59
|
+
printBrandBanner(process.stderr);
|
|
54
60
|
divider("─", 48, process.stderr);
|
|
55
61
|
writeStderr("Miru needs a Takara API key for code embeddings.");
|
|
56
62
|
hint("Get a bearer token from Takara, then enter it below.");
|
|
@@ -75,7 +81,7 @@ export async function runSetup(options: RunSetupOptions = {}): Promise<string> {
|
|
|
75
81
|
success(`Saved credentials to ${path}`);
|
|
76
82
|
hint("MCP loads this key from credentials.json automatically.");
|
|
77
83
|
writeStderr("");
|
|
78
|
-
return path;
|
|
84
|
+
return { path, newlySaved: true };
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
export async function runClearCredentials(): Promise<void> {
|
|
@@ -124,6 +130,11 @@ export async function ensureCredentials(options?: { interactive?: boolean }): Pr
|
|
|
124
130
|
return;
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
writeStderr("");
|
|
134
|
+
// Brand on stderr when credentials are missing in non-interactive mode (stdout may not be a TTY).
|
|
135
|
+
printBrandBanner(process.stderr);
|
|
136
|
+
writeStderr("");
|
|
137
|
+
|
|
127
138
|
throw new Error(
|
|
128
139
|
"Takara API key required. Run `miru setup` in a terminal, or set TAKARA_API_KEY " +
|
|
129
140
|
"in your MCP server env (Cursor mcp.json) or .env.local.",
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal helpers: ANSI styling, display-width layout, and brand RGB colors.
|
|
3
|
+
* Brand colors use truecolor when COLORTERM says so; otherwise 256-color palette
|
|
4
|
+
* (Terminal.app often omits COLORTERM but still renders 38;5 correctly).
|
|
5
|
+
*/
|
|
6
|
+
const ANSI = {
|
|
7
|
+
reset: "\x1b[0m",
|
|
8
|
+
bold: "\x1b[1m",
|
|
9
|
+
dim: "\x1b[2m",
|
|
10
|
+
cyan: "\x1b[36m",
|
|
11
|
+
green: "\x1b[32m",
|
|
12
|
+
red: "\x1b[31m",
|
|
13
|
+
yellow: "\x1b[33m",
|
|
14
|
+
magenta: "\x1b[35m",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export function colorEnabled(stream: NodeJS.WriteStream = process.stdout): boolean {
|
|
18
|
+
// https://no-color.org — any NO_COLOR value disables styling.
|
|
19
|
+
return stream.isTTY && process.env.NO_COLOR === undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function paint(text: string, code: string, stream?: NodeJS.WriteStream): string {
|
|
23
|
+
if (!colorEnabled(stream)) {
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
return `${code}${text}${ANSI.reset}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const style =
|
|
30
|
+
(code: string) =>
|
|
31
|
+
(text: string, stream?: NodeJS.WriteStream): string =>
|
|
32
|
+
paint(text, code, stream);
|
|
33
|
+
|
|
34
|
+
export const bold = style(ANSI.bold);
|
|
35
|
+
export const dim = style(ANSI.dim);
|
|
36
|
+
export const cyan = style(ANSI.cyan);
|
|
37
|
+
export const green = style(ANSI.green);
|
|
38
|
+
export const red = style(ANSI.red);
|
|
39
|
+
export const yellow = style(ANSI.yellow);
|
|
40
|
+
export const magenta = style(ANSI.magenta);
|
|
41
|
+
|
|
42
|
+
function charWidth(codePoint: number): number {
|
|
43
|
+
// East Asian and emoji runes count as two columns in most terminals.
|
|
44
|
+
if (codePoint === 0) {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
|
49
|
+
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
|
50
|
+
codePoint === 0xfeff
|
|
51
|
+
) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
|
56
|
+
(codePoint >= 0x2e80 && codePoint <= 0x303e) ||
|
|
57
|
+
(codePoint >= 0x3041 && codePoint <= 0x33ff) ||
|
|
58
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
|
|
59
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
|
|
60
|
+
(codePoint >= 0xa000 && codePoint <= 0xa4cf) ||
|
|
61
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
62
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
63
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
|
|
64
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
65
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
66
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
|
67
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
|
68
|
+
) {
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function displayWidth(text: string): number {
|
|
75
|
+
// Ignore ANSI SGR sequences when measuring layout width.
|
|
76
|
+
let width = 0;
|
|
77
|
+
let inEscape = false;
|
|
78
|
+
for (const char of text) {
|
|
79
|
+
if (inEscape) {
|
|
80
|
+
if (char === "m") {
|
|
81
|
+
inEscape = false;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === "\x1b") {
|
|
86
|
+
inEscape = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
width += charWidth(char.codePointAt(0) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return width;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function padLineToWidth(text: string, width: number): string {
|
|
95
|
+
const pad = Math.max(0, width - displayWidth(text));
|
|
96
|
+
return `${text}${" ".repeat(pad)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse `#rrggbb` into `[r, g, b]` for terminal truecolor/256-color SGR. */
|
|
100
|
+
export function hexToRgb(hex: string): readonly [number, number, number] {
|
|
101
|
+
const normalized = hex.trim().replace(/^#/, "");
|
|
102
|
+
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
|
|
103
|
+
throw new Error(`Invalid hex color: ${hex}`);
|
|
104
|
+
}
|
|
105
|
+
return [
|
|
106
|
+
Number.parseInt(normalized.slice(0, 2), 16),
|
|
107
|
+
Number.parseInt(normalized.slice(2, 4), 16),
|
|
108
|
+
Number.parseInt(normalized.slice(4, 6), 16),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function cropCommonLeading(rows: readonly string[]): string[] {
|
|
113
|
+
// Trim shared left margin from sampled art without shifting rows relative to each other.
|
|
114
|
+
const trimmed = rows.map((row) => row.trimEnd());
|
|
115
|
+
let minLead = trimmed[0]?.length ?? 0;
|
|
116
|
+
let hasContent = false;
|
|
117
|
+
|
|
118
|
+
for (const row of trimmed) {
|
|
119
|
+
if (!row.trim()) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
hasContent = true;
|
|
123
|
+
minLead = Math.min(minLead, row.length - row.trimStart().length);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!hasContent) {
|
|
127
|
+
return [...trimmed];
|
|
128
|
+
}
|
|
129
|
+
return trimmed.map((row) => row.slice(minLead));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rgbToAnsi256(r: number, g: number, b: number): number {
|
|
133
|
+
const avg = Math.round((r + g + b) / 3);
|
|
134
|
+
if (Math.abs(r - g) <= 3 && Math.abs(g - b) <= 3) {
|
|
135
|
+
if (avg < 8) {
|
|
136
|
+
return 16;
|
|
137
|
+
}
|
|
138
|
+
if (avg > 248) {
|
|
139
|
+
return 231;
|
|
140
|
+
}
|
|
141
|
+
return Math.round(((avg - 8) / 247) * 24) + 232;
|
|
142
|
+
}
|
|
143
|
+
return (
|
|
144
|
+
16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function brandSgr(rgb: readonly [number, number, number]): string {
|
|
149
|
+
const [r, g, b] = rgb;
|
|
150
|
+
const colorTerm = process.env.COLORTERM?.toLowerCase() ?? "";
|
|
151
|
+
// termstandard/colors: COLORTERM=truecolor|24bit → 24-bit SGR; else 256-color cube.
|
|
152
|
+
const truecolor =
|
|
153
|
+
process.env.FORCE_COLOR === "3" ||
|
|
154
|
+
colorTerm.includes("truecolor") ||
|
|
155
|
+
colorTerm.includes("24bit");
|
|
156
|
+
if (truecolor) {
|
|
157
|
+
return `\x1b[1;38;2;${r};${g};${b}m`;
|
|
158
|
+
}
|
|
159
|
+
return `\x1b[1;38;5;${rgbToAnsi256(r, g, b)}m`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function brandColor(
|
|
163
|
+
text: string,
|
|
164
|
+
rgb: readonly [number, number, number],
|
|
165
|
+
stream: NodeJS.WriteStream = process.stdout,
|
|
166
|
+
): string {
|
|
167
|
+
if (!colorEnabled(stream)) {
|
|
168
|
+
return text;
|
|
169
|
+
}
|
|
170
|
+
return `${brandSgr(rgb)}${text}${ANSI.reset}`;
|
|
171
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import packageJson from "../package.json";
|
|
4
3
|
import { resolveCacheFolder } from "./cache.ts";
|
|
@@ -49,13 +48,25 @@ export function isVersionNewer(latest: string, current: string): boolean {
|
|
|
49
48
|
return latest !== current;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
const [a0, a1, a2] = a;
|
|
52
|
+
const [b0, b1, b2] = b;
|
|
53
|
+
if (a0 > b0) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (a0 < b0) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (a1 > b1) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (a1 < b1) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (a2 > b2) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (a2 < b2) {
|
|
69
|
+
return false;
|
|
59
70
|
}
|
|
60
71
|
return false;
|
|
61
72
|
}
|
|
@@ -66,7 +77,7 @@ function updateCheckPath(): string {
|
|
|
66
77
|
|
|
67
78
|
async function readUpdateCheckCache(): Promise<UpdateCheckCache | null> {
|
|
68
79
|
try {
|
|
69
|
-
const raw = await
|
|
80
|
+
const raw = await Bun.file(updateCheckPath()).text();
|
|
70
81
|
const parsed = JSON.parse(raw) as UpdateCheckCache;
|
|
71
82
|
if (typeof parsed.checkedAt !== "number" || typeof parsed.latest !== "string") {
|
|
72
83
|
return null;
|
|
@@ -79,7 +90,7 @@ async function readUpdateCheckCache(): Promise<UpdateCheckCache | null> {
|
|
|
79
90
|
|
|
80
91
|
async function writeUpdateCheckCache(latest: string): Promise<void> {
|
|
81
92
|
const payload: UpdateCheckCache = { checkedAt: Date.now(), latest };
|
|
82
|
-
await
|
|
93
|
+
await Bun.write(updateCheckPath(), `${JSON.stringify(payload)}\n`);
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
export async function fetchLatestPublishedVersion(): Promise<string> {
|