march-cli 0.1.35 → 0.1.37

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 (63) hide show
  1. package/package.json +1 -1
  2. package/src/agent/code-search/cache.mjs +133 -0
  3. package/src/agent/code-search/chunk-rules.mjs +107 -0
  4. package/src/agent/code-search/chunker.mjs +125 -0
  5. package/src/agent/code-search/engine.mjs +109 -0
  6. package/src/agent/code-search/languages.mjs +25 -0
  7. package/src/agent/code-search/parser-pool.mjs +29 -0
  8. package/src/agent/code-search/rerank.mjs +43 -0
  9. package/src/agent/code-search/retrieval/bm25.mjs +47 -0
  10. package/src/agent/code-search/retrieval/fusion.mjs +18 -0
  11. package/src/agent/code-search/retrieval/model2vec.mjs +96 -0
  12. package/src/agent/code-search/retrieval/safetensors.mjs +49 -0
  13. package/src/agent/code-search/retrieval/vector.mjs +107 -0
  14. package/src/agent/code-search/retrieval/wordpiece.mjs +82 -0
  15. package/src/agent/code-search/scanner.mjs +84 -0
  16. package/src/agent/code-search/tokenize.mjs +16 -0
  17. package/src/agent/code-search/tool.mjs +75 -0
  18. package/src/agent/runner/provider-quota-runtime.mjs +38 -0
  19. package/src/agent/runner.mjs +14 -10
  20. package/src/agent/runtime/remote-runner-client.mjs +2 -0
  21. package/src/agent/runtime/runner-ipc-target.mjs +7 -0
  22. package/src/agent/runtime/runner-process-client.mjs +5 -0
  23. package/src/agent/runtime/runner-process-factory.mjs +5 -0
  24. package/src/agent/runtime/runner-runtime-host.mjs +2 -0
  25. package/src/agent/runtime/state/runner-state.mjs +1 -0
  26. package/src/agent/runtime/ui-event-bridge.mjs +2 -0
  27. package/src/agent/session/session-options.mjs +2 -1
  28. package/src/agent/tools.mjs +7 -1
  29. package/src/agent/turn/turn-events.mjs +41 -0
  30. package/src/agent/turn/turn-runner.mjs +5 -2
  31. package/src/cli/commands/registry/slash-command-registry.mjs +10 -7
  32. package/src/cli/commands/status-command.mjs +61 -35
  33. package/src/cli/input/history-store.mjs +65 -3
  34. package/src/cli/repl-loop.mjs +8 -6
  35. package/src/cli/startup/app-runtime.mjs +5 -29
  36. package/src/cli/startup/create-runtime-runner.mjs +4 -46
  37. package/src/cli/tui/input/history-navigation-controller.mjs +56 -0
  38. package/src/cli/turn/turn-input-preparer.mjs +0 -1
  39. package/src/cli/ui.mjs +9 -0
  40. package/src/context/engine.mjs +4 -2
  41. package/src/context/system-core/base.md +9 -1
  42. package/src/history/runner.mjs +11 -0
  43. package/src/history/store.mjs +129 -0
  44. package/src/history/tool.mjs +39 -0
  45. package/src/lsp/client.mjs +12 -5
  46. package/src/lsp/service.mjs +15 -3
  47. package/src/main.mjs +1 -2
  48. package/src/provider/quota/codex.mjs +278 -0
  49. package/src/provider/quota/index.mjs +46 -0
  50. package/src/provider/quota/transport-observer.mjs +99 -0
  51. package/src/web-ui/command.mjs +2 -2
  52. package/src/web-ui/runtime-host.mjs +7 -23
  53. package/src/web-ui/server.mjs +1 -0
  54. package/src/web-ui/session-manager.mjs +4 -2
  55. package/src/web-ui/src/components/AppShell.tsx +1 -0
  56. package/src/web-ui/src/components/RightSidebar.tsx +47 -2
  57. package/src/web-ui/src/model.ts +20 -0
  58. package/src/web-ui/src/runtime/client.ts +8 -1
  59. package/src/web-ui/src/runtime/useWebRuntime.ts +13 -1
  60. package/src/web-ui/src/styles/shell.css +10 -0
  61. package/src/web-ui/dist/assets/index-BUmhnID4.css +0 -1
  62. package/src/web-ui/dist/assets/index-CtuqTjcB.js +0 -1845
  63. package/src/web-ui/dist/index.html +0 -13
@@ -0,0 +1,96 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { readSafetensors } from "./safetensors.mjs";
5
+ import { normalizedVector } from "./vector.mjs";
6
+ import { WordPieceTokenizer } from "./wordpiece.mjs";
7
+
8
+ export const POTION_CODE_MODEL_ID = "minishlab/potion-code-16M";
9
+ const HF_RESOLVE_BASE = "https://huggingface.co";
10
+ const MODEL_FILES = ["config.json", "tokenizer.json", "model.safetensors"];
11
+
12
+ export class Model2VecVectorizer {
13
+ constructor({ modelDir, modelId = POTION_CODE_MODEL_ID, allowDownload = true } = {}) {
14
+ if (!modelDir) throw new Error("Model2VecVectorizer requires modelDir");
15
+ this.modelDir = modelDir;
16
+ this.modelId = modelId;
17
+ this.allowDownload = allowDownload;
18
+ this.id = `model2vec:${modelId}`;
19
+ this.dimensions = 256;
20
+ this.modelPromise = null;
21
+ }
22
+
23
+ async encode(texts) {
24
+ const model = await this.load();
25
+ return texts.map((text) => model.encode(text));
26
+ }
27
+
28
+ async load() {
29
+ this.modelPromise ??= this.loadModel();
30
+ return this.modelPromise;
31
+ }
32
+
33
+ async loadModel() {
34
+ await ensureModelFiles({ modelDir: this.modelDir, modelId: this.modelId, allowDownload: this.allowDownload });
35
+ const config = JSON.parse(await readFile(join(this.modelDir, "config.json"), "utf8"));
36
+ const tokenizerJson = JSON.parse(await readFile(join(this.modelDir, "tokenizer.json"), "utf8"));
37
+ const tensors = await readSafetensors(join(this.modelDir, "model.safetensors"));
38
+ const embeddings = tensors.getTensor(tensors.names.includes("embeddings") ? "embeddings" : "embedding.weight");
39
+ const weights = tensors.names.includes("weights") ? tensors.getTensor("weights") : null;
40
+ const mapping = tensors.names.includes("mapping") ? tensors.getTensor("mapping") : null;
41
+ const model = new LoadedModel2Vec({ config, tokenizerJson, embeddings, weights, mapping });
42
+ this.dimensions = model.dimensions;
43
+ return model;
44
+ }
45
+ }
46
+
47
+ export async function ensureModelFiles({ modelDir, modelId = POTION_CODE_MODEL_ID, allowDownload = true }) {
48
+ const missing = MODEL_FILES.filter((name) => !existsSync(join(modelDir, name)));
49
+ if (missing.length === 0) return;
50
+ if (!allowDownload) throw new Error(`Missing Model2Vec files in ${modelDir}: ${missing.join(", ")}`);
51
+ await mkdir(modelDir, { recursive: true });
52
+ for (const name of missing) await downloadModelFile({ modelDir, modelId, name });
53
+ }
54
+
55
+ class LoadedModel2Vec {
56
+ constructor({ config, tokenizerJson, embeddings, weights, mapping }) {
57
+ this.normalize = config?.normalize !== false;
58
+ this.tokenizer = new WordPieceTokenizer(tokenizerJson);
59
+ this.embeddings = embeddings.values;
60
+ this.vocabularySize = embeddings.shape[0];
61
+ this.dimensions = embeddings.shape[1];
62
+ this.weights = weights?.values ?? null;
63
+ this.mapping = mapping?.values ?? null;
64
+ }
65
+
66
+ encode(text) {
67
+ const ids = this.tokenizer.encode(text);
68
+ const values = new Float32Array(this.dimensions);
69
+ let count = 0;
70
+ for (const id of ids) {
71
+ const mapped = this.mapping ? this.mapping[id] : id;
72
+ if (mapped == null || mapped < 0 || mapped >= this.vocabularySize) continue;
73
+ const weight = this.weights?.[id] ?? 1;
74
+ const offset = mapped * this.dimensions;
75
+ for (let dim = 0; dim < this.dimensions; dim += 1) values[dim] += this.embeddings[offset + dim] * weight;
76
+ count += 1;
77
+ }
78
+ if (count > 0) for (let dim = 0; dim < this.dimensions; dim += 1) values[dim] /= count;
79
+ const vector = normalizedVector(values);
80
+ if (this.normalize && vector.norm > 0) {
81
+ for (let dim = 0; dim < values.length; dim += 1) values[dim] /= vector.norm;
82
+ return normalizedVector(values);
83
+ }
84
+ return vector;
85
+ }
86
+ }
87
+
88
+ async function downloadModelFile({ modelDir, modelId, name }) {
89
+ const url = `${HF_RESOLVE_BASE}/${modelId}/resolve/main/${name}`;
90
+ const response = await fetch(url, { redirect: "follow" });
91
+ if (!response.ok) throw new Error(`Failed to download ${modelId}/${name}: HTTP ${response.status}`);
92
+ const target = join(modelDir, name);
93
+ const tmpPath = join(dirname(target), `${basename(target)}.${process.pid}.tmp`);
94
+ await writeFile(tmpPath, new Uint8Array(await response.arrayBuffer()));
95
+ await rename(tmpPath, target);
96
+ }
@@ -0,0 +1,49 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ const HEADER_BYTES = 8;
4
+ const DTYPE_READERS = {
5
+ F32: { size: 4, read: (view, offset) => view.getFloat32(offset, true) },
6
+ F64: { size: 8, read: (view, offset) => view.getFloat64(offset, true) },
7
+ I32: { size: 4, read: (view, offset) => view.getInt32(offset, true) },
8
+ I64: { size: 8, read: (view, offset) => Number(view.getBigInt64(offset, true)) },
9
+ U32: { size: 4, read: (view, offset) => view.getUint32(offset, true) },
10
+ U64: { size: 8, read: (view, offset) => Number(view.getBigUint64(offset, true)) },
11
+ };
12
+
13
+ export async function readSafetensors(filePath) {
14
+ const buffer = await readFile(filePath);
15
+ return parseSafetensors(buffer);
16
+ }
17
+
18
+ export function parseSafetensors(buffer) {
19
+ const data = toArrayBuffer(buffer);
20
+ const headerLength = Number(new DataView(data, 0, HEADER_BYTES).getBigUint64(0, true));
21
+ const headerStart = HEADER_BYTES;
22
+ const headerEnd = headerStart + headerLength;
23
+ const headerJson = new TextDecoder().decode(new Uint8Array(data, headerStart, headerLength));
24
+ const header = JSON.parse(headerJson);
25
+ return {
26
+ names: Object.keys(header).filter((name) => name !== "__metadata__"),
27
+ getTensor(name) {
28
+ const descriptor = header[name];
29
+ if (!descriptor) throw new Error(`Missing safetensors tensor: ${name}`);
30
+ return readTensor(data, headerEnd, descriptor);
31
+ },
32
+ };
33
+ }
34
+
35
+ function readTensor(data, dataStart, descriptor) {
36
+ const reader = DTYPE_READERS[descriptor.dtype];
37
+ if (!reader) throw new Error(`Unsupported safetensors dtype: ${descriptor.dtype}`);
38
+ const [start, end] = descriptor.data_offsets;
39
+ const byteOffset = dataStart + start;
40
+ const length = (end - start) / reader.size;
41
+ const view = new DataView(data, byteOffset, end - start);
42
+ const values = new Float32Array(length);
43
+ for (let index = 0; index < length; index += 1) values[index] = reader.read(view, index * reader.size);
44
+ return { values, shape: descriptor.shape, dtype: descriptor.dtype };
45
+ }
46
+
47
+ function toArrayBuffer(buffer) {
48
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
49
+ }
@@ -0,0 +1,107 @@
1
+ import { tokenize } from "../tokenize.mjs";
2
+
3
+ const DEFAULT_DIMENSIONS = 256;
4
+ const SEMANTIC_MIN_SCORE = 0.05;
5
+
6
+ export class LocalVectorIndex {
7
+ constructor(chunks, vectors, { vectorizer = defaultVectorizer } = {}) {
8
+ this.chunks = chunks;
9
+ this.dimensions = vectorizer.dimensions;
10
+ this.vectorizer = vectorizer;
11
+ this.vectors = vectors;
12
+ }
13
+
14
+ static async create(chunks, { vectorizer = defaultVectorizer } = {}) {
15
+ const vectors = await vectorizer.encode(chunks.map(chunkVectorText));
16
+ return new LocalVectorIndex(chunks, vectors, { vectorizer });
17
+ }
18
+
19
+ async search(query, { limit = 50 } = {}) {
20
+ const [queryVector] = await this.vectorizer.encode([query]);
21
+ return this.searchVector(queryVector, { limit });
22
+ }
23
+
24
+ searchVector(queryVector, { limit = 50 } = {}) {
25
+ if (queryVector.norm === 0) return [];
26
+ const scored = [];
27
+ for (let index = 0; index < this.vectors.length; index += 1) {
28
+ const score = cosineSimilarity(queryVector, this.vectors[index]);
29
+ if (score >= SEMANTIC_MIN_SCORE) scored.push({ chunk: this.chunks[index], score });
30
+ }
31
+ scored.sort((a, b) => b.score - a.score || a.chunk.file_path.localeCompare(b.chunk.file_path));
32
+ return scored.slice(0, limit);
33
+ }
34
+ }
35
+
36
+ export class HashingVectorizer {
37
+ constructor({ dimensions = DEFAULT_DIMENSIONS } = {}) {
38
+ this.id = `hashing-${dimensions}`;
39
+ this.dimensions = dimensions;
40
+ }
41
+
42
+ async encode(texts) {
43
+ return texts.map((text) => vectorizeText(text, this.dimensions));
44
+ }
45
+ }
46
+
47
+ export const defaultVectorizer = new HashingVectorizer();
48
+
49
+ function chunkVectorText(chunk) {
50
+ return [
51
+ chunk.file_path,
52
+ chunk.symbols.join(" "),
53
+ chunk.identifiers.join(" "),
54
+ chunk.content,
55
+ ].join("\n");
56
+ }
57
+
58
+ function vectorizeText(text, dimensions) {
59
+ const values = new Float32Array(dimensions);
60
+ const tokens = tokenize(text);
61
+ for (const token of tokens) {
62
+ addFeature(values, token, 1);
63
+ for (const gram of charTrigrams(token)) addFeature(values, gram, 0.35);
64
+ }
65
+ return normalizedVector(values);
66
+ }
67
+
68
+ export function normalizedVector(values) {
69
+ return { values, norm: vectorNorm(values) };
70
+ }
71
+
72
+ function addFeature(values, feature, weight) {
73
+ const hash = hashString(feature);
74
+ const index = hash % values.length;
75
+ const sign = hash & 1 ? 1 : -1;
76
+ values[index] += sign * weight;
77
+ }
78
+
79
+ function charTrigrams(token) {
80
+ const padded = `^${token}$`;
81
+ if (padded.length <= 3) return [padded];
82
+ const grams = [];
83
+ for (let index = 0; index <= padded.length - 3; index += 1) grams.push(padded.slice(index, index + 3));
84
+ return grams;
85
+ }
86
+
87
+ function cosineSimilarity(left, right) {
88
+ if (left.norm === 0 || right.norm === 0) return 0;
89
+ let dot = 0;
90
+ for (let index = 0; index < left.values.length; index += 1) dot += left.values[index] * right.values[index];
91
+ return dot / (left.norm * right.norm);
92
+ }
93
+
94
+ function vectorNorm(values) {
95
+ let sum = 0;
96
+ for (const value of values) sum += value * value;
97
+ return Math.sqrt(sum);
98
+ }
99
+
100
+ function hashString(value) {
101
+ let hash = 2166136261;
102
+ for (let index = 0; index < value.length; index += 1) {
103
+ hash ^= value.charCodeAt(index);
104
+ hash = Math.imul(hash, 16777619);
105
+ }
106
+ return hash >>> 0;
107
+ }
@@ -0,0 +1,82 @@
1
+ const MAX_INPUT_CHARS_PER_WORD = 100;
2
+
3
+ export class WordPieceTokenizer {
4
+ constructor(tokenizerJson) {
5
+ const model = tokenizerJson?.model ?? {};
6
+ this.vocab = model.vocab ?? {};
7
+ this.unkToken = model.unk_token ?? "[UNK]";
8
+ this.unkId = this.vocab[this.unkToken] ?? 1;
9
+ this.prefix = model.continuing_subword_prefix ?? "##";
10
+ this.lowercase = tokenizerJson?.normalizer?.lowercase !== false;
11
+ this.maxInputCharsPerWord = model.max_input_chars_per_word ?? MAX_INPUT_CHARS_PER_WORD;
12
+ }
13
+
14
+ encode(text, { maxLength = 512 } = {}) {
15
+ const ids = [];
16
+ for (const token of preTokenize(normalizeText(text, { lowercase: this.lowercase }))) {
17
+ for (const id of this.wordPiece(token)) {
18
+ if (id !== this.unkId) ids.push(id);
19
+ if (ids.length >= maxLength) return ids;
20
+ }
21
+ }
22
+ return ids;
23
+ }
24
+
25
+ wordPiece(token) {
26
+ if (!token) return [];
27
+ if (token.length > this.maxInputCharsPerWord) return [this.unkId];
28
+ const ids = [];
29
+ let start = 0;
30
+ while (start < token.length) {
31
+ let end = token.length;
32
+ let current = null;
33
+ while (start < end) {
34
+ const piece = start === 0 ? token.slice(start, end) : `${this.prefix}${token.slice(start, end)}`;
35
+ if (Object.hasOwn(this.vocab, piece)) {
36
+ current = piece;
37
+ break;
38
+ }
39
+ end -= 1;
40
+ }
41
+ if (current === null) return [this.unkId];
42
+ ids.push(this.vocab[current]);
43
+ start = end;
44
+ }
45
+ return ids;
46
+ }
47
+ }
48
+
49
+ function normalizeText(text, { lowercase }) {
50
+ let normalized = String(text ?? "").replace(/[\t\n\r]+/g, " ");
51
+ normalized = normalized.replace(/[\u0000-\u001f\u007f]/g, " ");
52
+ return lowercase ? normalized.toLowerCase() : normalized;
53
+ }
54
+
55
+ function preTokenize(text) {
56
+ const tokens = [];
57
+ let current = "";
58
+ for (const char of text) {
59
+ if (/\s/u.test(char)) {
60
+ pushCurrent();
61
+ } else if (isPunctuation(char)) {
62
+ pushCurrent();
63
+ tokens.push(char);
64
+ } else {
65
+ current += char;
66
+ }
67
+ }
68
+ pushCurrent();
69
+ return tokens;
70
+
71
+ function pushCurrent() {
72
+ if (current) tokens.push(current);
73
+ current = "";
74
+ }
75
+ }
76
+
77
+ function isPunctuation(char) {
78
+ const code = char.codePointAt(0);
79
+ if ((code >= 33 && code <= 47) || (code >= 58 && code <= 64)) return true;
80
+ if ((code >= 91 && code <= 96) || (code >= 123 && code <= 126)) return true;
81
+ return /\p{P}/u.test(char);
82
+ }
@@ -0,0 +1,84 @@
1
+ import { execFile } from "node:child_process";
2
+ import { lstat, readdir, readFile, stat } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { resolveRipgrepCommand } from "../../memory/markdown/ripgrep.mjs";
5
+ import { isSearchableTextPath, languageForPath } from "./languages.mjs";
6
+
7
+ const DEFAULT_MAX_FILES = 2_000;
8
+ const DEFAULT_MAX_FILE_BYTES = 512 * 1024;
9
+ const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", "coverage", ".next", ".turbo", ".cache"]);
10
+
11
+ export async function scanCodeFiles({ root, path = ".", maxFiles = DEFAULT_MAX_FILES, maxFileBytes = DEFAULT_MAX_FILE_BYTES } = {}) {
12
+ const rootPath = resolve(root);
13
+ const base = resolve(rootPath, path);
14
+ const baseRel = relative(rootPath, base);
15
+ if (baseRel.startsWith("..") || isAbsolute(baseRel)) throw new Error(`Search path escapes workspace: ${path}`);
16
+ let baseInfo;
17
+ try { baseInfo = await stat(base); } catch { return []; }
18
+ const candidates = baseInfo.isFile()
19
+ ? [relative(rootPath, base).replace(/\\/g, "/")]
20
+ : await listCandidateFiles(rootPath, base);
21
+ const files = [];
22
+ for (const relPath of [...candidates].sort()) {
23
+ if (files.length >= maxFiles) break;
24
+ if (!isSearchableTextPath(relPath)) continue;
25
+ const absPath = resolve(root, relPath);
26
+ let info;
27
+ try { info = await stat(absPath); } catch { continue; }
28
+ if (!info.isFile() || info.size > maxFileBytes) continue;
29
+ const content = await readUtf8Text(absPath);
30
+ if (content === null) continue;
31
+ files.push({
32
+ absPath,
33
+ relPath: relPath.replace(/\\/g, "/"),
34
+ language: languageForPath(relPath),
35
+ content,
36
+ size: info.size,
37
+ mtimeMs: info.mtimeMs,
38
+ });
39
+ }
40
+ return files;
41
+ }
42
+
43
+ async function listCandidateFiles(root, base) {
44
+ const fromRipgrep = await listRipgrepFiles(root, base);
45
+ if (fromRipgrep) return fromRipgrep;
46
+ return listFilesRecursively(root, base);
47
+ }
48
+
49
+ function listRipgrepFiles(root, base) {
50
+ return new Promise((resolveResult) => {
51
+ const command = resolveRipgrepCommand();
52
+ const args = ["--files", "--hidden", "--glob", "!.git/**", "--glob", "!node_modules/**"];
53
+ execFile(command, args, { cwd: base, maxBuffer: 50 * 1024 * 1024 }, (error, stdout) => {
54
+ if (error) return resolveResult(null);
55
+ const baseRel = relative(root, base).replace(/\\/g, "/");
56
+ const prefix = baseRel ? `${baseRel}/` : "";
57
+ resolveResult(stdout.split(/\r?\n/).filter(Boolean).map((file) => `${prefix}${file.replace(/\\/g, "/")}`));
58
+ });
59
+ });
60
+ }
61
+
62
+ async function listFilesRecursively(root, dir) {
63
+ let entries;
64
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
65
+ const files = [];
66
+ for (const entry of entries) {
67
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
68
+ const absPath = join(dir, entry.name);
69
+ if (entry.isDirectory()) files.push(...await listFilesRecursively(root, absPath));
70
+ else if (entry.isFile()) files.push(relative(root, absPath).replace(/\\/g, "/"));
71
+ }
72
+ return files;
73
+ }
74
+
75
+ async function readUtf8Text(path) {
76
+ try {
77
+ if ((await lstat(path)).isSymbolicLink()) return null;
78
+ const buffer = await readFile(path);
79
+ if (buffer.includes(0)) return null;
80
+ return buffer.toString("utf8");
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
@@ -0,0 +1,16 @@
1
+ const STOP_WORDS = new Set([
2
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "in", "is", "it", "of", "on", "or", "that", "the", "this", "to", "with",
3
+ ]);
4
+
5
+ export function tokenize(text) {
6
+ const raw = String(text ?? "")
7
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
8
+ .replace(/[_\-./\\:]+/g, " ")
9
+ .toLowerCase()
10
+ .match(/[a-z0-9]+/g) ?? [];
11
+ return raw.filter((token) => token.length > 1 && !STOP_WORDS.has(token));
12
+ }
13
+
14
+ export function uniqueTokens(text) {
15
+ return [...new Set(tokenize(text))];
16
+ }
@@ -0,0 +1,75 @@
1
+ import { join } from "node:path";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { toolText } from "../tool-result.mjs";
5
+ import { CodeSearchIndexCache } from "./cache.mjs";
6
+ import { searchCode } from "./engine.mjs";
7
+ import { Model2VecVectorizer, POTION_CODE_MODEL_ID } from "./retrieval/model2vec.mjs";
8
+
9
+ const persistentCaches = new Map();
10
+
11
+ export function createCodeSearchTool({ engine, stateRoot = null }) {
12
+ const cache = stateRoot ? persistentCacheFor(stateRoot) : null;
13
+ return defineTool({
14
+ name: "code_search",
15
+ label: "Code Search",
16
+ description: "Native semantic/symbol code search over the workspace. Use it first for unknown entry points, cross-module flows, responsibility boundaries, and related implementations. Use grep/read afterward for exact confirmation before editing or making claims.",
17
+ parameters: Type.Object({
18
+ query: Type.Optional(Type.String({ description: "Natural-language or symbol query" })),
19
+ path: Type.Optional(Type.String({ description: "Relative or absolute workspace path to search; default current workspace" })),
20
+ top_k: Type.Optional(Type.Number({ description: "Maximum results to return; default 5, max 20" })),
21
+ mode: Type.Optional(Type.Union([
22
+ Type.Literal("auto"),
23
+ Type.Literal("symbol"),
24
+ Type.Literal("lexical"),
25
+ Type.Literal("semantic"),
26
+ ], { description: "Search mode. auto uses BM25 + Model2Vec retrieval with RRF fusion." })),
27
+ include_tests: Type.Optional(Type.Boolean({ description: "Include test/spec paths without penalty; default false" })),
28
+ related_to: Type.Optional(Type.Object({
29
+ file_path: Type.String({ description: "Workspace-relative file path containing the known code" }),
30
+ line: Type.Number({ description: "Line inside the known code chunk" }),
31
+ }, { description: "Find code related to a known file location; query can optionally refine the relation" })),
32
+ }),
33
+ execute: async (_toolCallId, params) => executeCodeSearch({ engine, cache, ...params }),
34
+ });
35
+ }
36
+
37
+ export async function executeCodeSearch({ engine, cache, query, path = ".", top_k, mode = "auto", include_tests = false, related_to }) {
38
+ try {
39
+ const root = engine.cwd;
40
+ const searchPath = path === "." ? "." : engine.resolvePath(path);
41
+ const result = await searchCode({ root, query, path: searchPath, top_k, mode, include_tests, related_to, cache });
42
+ return toolText(formatSearchOutput(result), result);
43
+ } catch (err) {
44
+ return toolText(`Error running code_search: ${err.message}`, { error: true });
45
+ }
46
+ }
47
+
48
+ function persistentCacheFor(stateRoot) {
49
+ const storagePath = join(stateRoot, "code-search", "chunks.json");
50
+ const modelDir = join(stateRoot, "code-search", "models", POTION_CODE_MODEL_ID.replaceAll("/", "__"));
51
+ const cacheKey = `${storagePath}\n${modelDir}`;
52
+ let cache = persistentCaches.get(cacheKey);
53
+ if (!cache) {
54
+ cache = new CodeSearchIndexCache({
55
+ storagePath,
56
+ vectorizer: new Model2VecVectorizer({ modelDir }),
57
+ });
58
+ persistentCaches.set(cacheKey, cache);
59
+ }
60
+ return cache;
61
+ }
62
+
63
+ function formatSearchOutput({ results, stats }) {
64
+ const header = `--- code_search (${results.length} results, ${stats.files} files, ${stats.chunks} chunks, ${stats.mode}) ---`;
65
+ if (results.length === 0) return `${header}\nNo matching code snippets found.`;
66
+ const body = results.map((result, index) => [
67
+ `${index + 1}. ${result.file_path}:${result.start_line}-${result.end_line} score=${result.score} kind=${result.kind}${result.symbols.length ? ` symbols=${result.symbols.join(",")}` : ""}`,
68
+ fenceSnippet(result.snippet),
69
+ ].join("\n")).join("\n\n");
70
+ return `${header}\n${body}`;
71
+ }
72
+
73
+ function fenceSnippet(snippet) {
74
+ return "```\n" + snippet.trimEnd() + "\n```";
75
+ }
@@ -0,0 +1,38 @@
1
+ import { createProviderQuotaService } from "../../provider/quota/index.mjs";
2
+ import { installProviderQuotaTransportObserver, subscribeProviderQuotaTransport } from "../../provider/quota/transport-observer.mjs";
3
+
4
+ export function createRunnerProviderQuotaRuntime({ authStorage, getCurrentModel, ui } = {}) {
5
+ installProviderQuotaTransportObserver();
6
+ const providerQuota = createProviderQuotaService({ authStorage });
7
+ let lastSnapshot = null;
8
+ const unsubscribeTransport = subscribeProviderQuotaTransport((event) => {
9
+ const model = getCurrentModel?.();
10
+ if (!providerQuota.supports(model?.provider) || event.providerId !== model.provider) return;
11
+ const snapshot = event.source === "headers"
12
+ ? providerQuota.observeHeaders(event.headers, model)
13
+ : providerQuota.observeEvent(event.payload, model);
14
+ if (!snapshot) return;
15
+ lastSnapshot = snapshot;
16
+ ui?.providerQuotaSnapshot?.(lastSnapshot);
17
+ });
18
+
19
+ return {
20
+ getCachedProviderQuotaSnapshot() {
21
+ return lastSnapshot;
22
+ },
23
+ async getProviderQuotaSnapshot({ emit = false } = {}) {
24
+ const model = getCurrentModel?.();
25
+ if (!providerQuota.supports(model?.provider)) {
26
+ lastSnapshot = null;
27
+ if (emit) ui?.providerQuotaSnapshot?.(null);
28
+ return null;
29
+ }
30
+ lastSnapshot = await providerQuota.refresh(model);
31
+ if (emit) ui?.providerQuotaSnapshot?.(lastSnapshot);
32
+ return lastSnapshot;
33
+ },
34
+ disposeProviderQuotaRuntime() {
35
+ unsubscribeTransport();
36
+ },
37
+ };
38
+ }
@@ -27,19 +27,17 @@ import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
27
27
  import { registerCustomProviders } from "../provider/custom-provider.mjs";
28
28
  import { injectHostedTools } from "../provider/hosted-tools.mjs";
29
29
  import { createRunnerLifecycle } from "./lifecycle/runner-lifecycle.mjs";
30
+ import { createRunnerProviderQuotaRuntime } from "./runner/provider-quota-runtime.mjs";
31
+ import { appendRunnerTurnHistory, createRunnerHistoryStore } from "../history/runner.mjs";
30
32
  export { MARCH_BASE_TOOL_NAMES, installModelPayloadDumper };
31
33
  export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
32
34
  export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
33
- export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, profilePaths = null, memoryStore = null, memoryTools = [], remoteMemorySources = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, logger = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, hostedTools = {} }) {
35
+ export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, profilePaths = null, memoryStore = null, memoryTools = [], remoteMemorySources = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, logger = null, onModelPayload = null, onLspStatusChange = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, hostedTools = {} }) {
34
36
  installCodexLargeContextGuard();
35
37
  installCodexTransportCompression();
36
38
  installCodexWebSocketEventDebug();
37
- if (!useRuntimeHost && extensionPaths.length > 0) {
38
- throw new Error("--extension requires the default pi runtime host path");
39
- }
40
- const authConfig = authStorage
41
- ? { authStorage, hasAuth: true }
42
- : createMarchAuthStorage({ provider: provider ?? "deepseek", providers, cwd });
39
+ if (!useRuntimeHost && extensionPaths.length > 0) throw new Error("--extension requires the default pi runtime host path");
40
+ const authConfig = authStorage ? { authStorage, hasAuth: true } : createMarchAuthStorage({ provider: provider ?? "deepseek", providers, cwd });
43
41
  if (!authConfig.hasAuth) throw new Error("No providers configured. Run: march provider --config");
44
42
  const resolvedAuth = authConfig.authStorage;
45
43
  const modelRegistry = ModelRegistry.create(resolvedAuth);
@@ -54,8 +52,9 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
54
52
  retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
55
53
  });
56
54
  const { ui: runtimeUi, eventBus: runtimeUiEvents, detach: detachRuntimeUi } = createRuntimeUiBridge(ui);
57
- const lspService = new LspService({ cwd, onEvent: (event) => runtimeUi.status?.(formatLspServiceEvent(event)) });
55
+ const lspService = new LspService({ cwd, onEvent: (event) => runtimeUi.status?.(formatLspServiceEvent(event)), onStatusChange: (event) => onLspStatusChange?.(event) });
58
56
  const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, profilePaths, remoteMemorySources, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
57
+ const historyStore = createRunnerHistoryStore({ stateRoot, cwd });
59
58
  const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
60
59
  const sessionBinding = createSessionBinding(null);
61
60
  let currentModelCallKind = "model", currentTurnId = null, currentPromptForContext = "";
@@ -71,7 +70,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
71
70
  providers,
72
71
  sessionManager: resolvedSessionManager, sessionBinding, engine, ui: runtimeUi,
73
72
  projectMarchDir,
74
- memoryTools, memoryStore, shellRuntime, lspService, mcpTools, webTools,
73
+ memoryTools, memoryStore, historyStore, shellRuntime, lspService, mcpTools, webTools,
75
74
  lifecycle, permissionController, extensionPaths, hostedTools,
76
75
  onRebind: (session) => {
77
76
  installModelPayloadDumper(session, modelContextDumper, () => currentModelCallKind, onLoggedModelPayload, injectMarchSystemContext);
@@ -84,7 +83,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
84
83
  } else {
85
84
  const sessionOptions = resolveRunnerSessionOptions({
86
85
  cwd, stateRoot, provider, modelId, modelRegistry, engine, ui: runtimeUi,
87
- memoryTools, shellRuntime, lspService, mcpTools, webTools, lifecycle, permissionController,
86
+ memoryTools, historyStore, shellRuntime, lspService, mcpTools, webTools, lifecycle, permissionController,
88
87
  authStorage: resolvedAuth, projectMarchDir,
89
88
  getCurrentModel: () => sessionBinding.get()?.model ?? selectedModel,
90
89
  });
@@ -107,6 +106,8 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
107
106
  if (serviceTier === "priority" && selectedModel && isFastProvider(selectedModel.provider)) {
108
107
  _currentFastEntry = createFastModelEntry(selectedModel).model;
109
108
  }
109
+ const providerQuotaRuntime = createRunnerProviderQuotaRuntime({ authStorage: resolvedAuth, ui: runtimeUi,
110
+ getCurrentModel: () => _currentFastEntry ?? sessionBinding.get().model });
110
111
  return {
111
112
  engine,
112
113
  get session() { return sessionBinding.get(); },
@@ -131,6 +132,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
131
132
  syncCurrentPiSidecar,
132
133
  autoNameSession,
133
134
  contextMode,
135
+ recordHistory: (turn) => appendRunnerTurnHistory({ store: historyStore, turn, sessionStats: getRunnerSessionStats(sessionBinding.get(), runtimeHost), modelId: engine.modelId, provider: engine.provider }),
134
136
  });
135
137
  notifyTurnEndDetached(turnNotifier, {
136
138
  status: "success",
@@ -176,6 +178,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
176
178
  return result;
177
179
  },
178
180
  getCurrentModel() { return _currentFastEntry ?? sessionBinding.get().model; },
181
+ ...providerQuotaRuntime,
179
182
  async setModel(model) {
180
183
  const activeSession = sessionBinding.get();
181
184
  const { baseId, isFast } = fromFastEntryModel(model);
@@ -249,6 +252,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
249
252
  () => shellRuntime?.dispose?.() ?? shellRuntime?.killAll?.(),
250
253
  () => lspService.dispose(),
251
254
  () => mcpClientManager?.disconnectAll?.(),
255
+ () => providerQuotaRuntime.disposeProviderQuotaRuntime(),
252
256
  () => detachRuntimeUi(),
253
257
  ]);
254
258
  },
@@ -26,6 +26,8 @@ export function createRemoteRunnerClient(peer, { initialState = null } = {}) {
26
26
  getScopedModels: () => state?.scopedModels ?? [],
27
27
  getConfiguredProviders: () => state?.configuredProviders ?? [],
28
28
  getSessionStats: () => state?.sessionStats ?? null,
29
+ getCachedProviderQuotaSnapshot: () => state?.providerQuota ?? null,
30
+ async getProviderQuotaSnapshot(options = {}) { return applyResultWithState(await peer.call("getProviderQuotaSnapshot", options)); },
29
31
  async refreshState() { return refreshState(); },
30
32
  getLastNotificationResult: () => peer.call("getLastNotificationResult"),
31
33
  notifyTest: (options) => peer.call("notifyTest", options),
@@ -37,6 +37,13 @@ export function createRunnerIpcTarget({ createRunnerImpl, runnerOptions = {} } =
37
37
  getSessionStats() {
38
38
  return getRunner().getSessionStats();
39
39
  },
40
+ async getProviderQuotaSnapshot(options = {}) {
41
+ const result = await getRunner().getProviderQuotaSnapshot(options);
42
+ return { result, state: getRunnerState(runner) };
43
+ },
44
+ getCachedProviderQuotaSnapshot() {
45
+ return getRunner().getCachedProviderQuotaSnapshot?.() ?? null;
46
+ },
40
47
  getState() {
41
48
  return getRunnerState(getRunner());
42
49
  },