trueline-mcp 2.2.0 → 2.4.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 CHANGED
@@ -8,7 +8,7 @@ VS Code Copilot, OpenCode, and Codex CLI.
8
8
 
9
9
  ## Installation
10
10
 
11
- **Claude Code** (recommended hooks are automatic):
11
+ **Claude Code** (recommended; hooks are automatic):
12
12
 
13
13
  ```
14
14
  /plugin marketplace add rjkaes/trueline-mcp
@@ -23,7 +23,7 @@ See [INSTALL.md](INSTALL.md) for platform-specific setup instructions.
23
23
  AI agents waste tokens in two ways:
24
24
 
25
25
  1. **Reading too much.** To find a function in a 500-line file, the agent
26
- reads all 500 lines most of which it doesn't need.
26
+ reads all 500 lines, most of which it doesn't need.
27
27
 
28
28
  2. **Echoing on edit.** The built-in `Edit` tool requires the agent to
29
29
  output the old text being replaced (`old_string`) plus the new text.
@@ -32,19 +32,19 @@ AI agents waste tokens in two ways:
32
32
  Both problems compound. A typical editing session reads dozens of files
33
33
  and makes multiple edits, burning through context on redundant content.
34
34
 
35
- And when things go wrong stale reads, hallucinated anchors, ambiguous
36
- matches the agent silently corrupts your code.
35
+ And when things go wrong (stale reads, hallucinated anchors, ambiguous
36
+ matches) the agent silently corrupts your code.
37
37
 
38
38
  ## How trueline fixes this
39
39
 
40
- trueline replaces Claude Code's built-in `Read` and `Edit` with four
41
- tools that are smaller, faster, and verified.
40
+ trueline replaces the built-in `Read` and `Edit` with five tools that
41
+ are smaller, faster, and verified.
42
42
 
43
- ### 95% fewer input tokens with `trueline_outline`
43
+ ### Read less: `trueline_outline` + `trueline_read`
44
44
 
45
- Instead of reading an entire file to understand its structure,
46
- `trueline_outline` returns a compact AST outline just the functions,
47
- classes, types, and declarations with their line ranges:
45
+ Instead of reading an entire file, the agent starts with
46
+ `trueline_outline`, a compact AST outline showing just the functions,
47
+ classes, and declarations with their line ranges:
48
48
 
49
49
  ```
50
50
  1-10: (10 imports)
@@ -57,150 +57,53 @@ classes, types, and declarations with their line ranges:
57
57
  (12 symbols, 139 source lines)
58
58
  ```
59
59
 
60
- 12 lines instead of 139. The agent sees the full structure, picks the
61
- ranges it needs, and reads only those — skipping hundreds of irrelevant
62
- lines.
60
+ 12 lines instead of 139. The agent sees the full structure, then reads
61
+ only the ranges it needs, skipping hundreds of irrelevant lines.
63
62
 
64
63
  | File size | Full read | Outline | Savings |
65
- |-------------|-----------|---------|---------|
64
+ |-------------|-----------|---------|--------|
66
65
  | 140 lines | 140 tokens | 12 tokens | 91% |
67
66
  | 245 lines | 245 tokens | 14 tokens | 94% |
68
67
  | 504 lines | 504 tokens | 4 tokens | 99% |
69
68
 
70
- Every line range in the outline maps directly to a `trueline_read` call.
69
+ `trueline_read` supports multiple disjoint ranges in a single call and
70
+ an optional `hashes: false` mode for exploratory reads that saves ~3
71
+ tokens per line.
71
72
 
72
- Supports 20+ languages: TypeScript, JavaScript, Python, Go, Rust, Java, C,
73
- C++, C#, Ruby, PHP, Kotlin, Swift, Scala, Elixir, Lua, Dart, Zig, Bash.
73
+ ### Find and fix: `trueline_search`
74
74
 
75
- ### 44% fewer output tokens with `trueline_edit`
75
+ When the agent knows what it's looking for, `trueline_search` finds
76
+ lines by regex and returns them with enough context to edit immediately,
77
+ no outline or read step needed.
76
78
 
77
- The built-in `Edit` makes the model echo back the text being replaced:
79
+ A search-based workflow uses **~127 tokens** vs **~2000** for
80
+ outline+read, a 93% reduction for targeted lookups.
78
81
 
79
- ```json
80
- // Built-in Edit — model must output the old text
81
- {
82
- "old_string": "export function handleRequest(req: Request) {\n ...\n}",
83
- "new_string": "export function handleRequest(req: Request) {\n ...(new)...\n}"
84
- }
85
- ```
86
-
87
- `trueline_edit` replaces old text with a compact line-range reference:
88
-
89
- ```json
90
- // trueline_edit — just the range and the new content
91
- {
92
- "edits": [{
93
- "checksum": "1-50:a3b1c2d4",
94
- "range": "12:kf..16:qz",
95
- "content": "export function handleRequest(req: Request) {\n ...(new)...\n}"
96
- }]
97
- }
98
- ```
99
-
100
- The model never echoes old text. For a typical 15-line edit:
101
-
102
- | | Built-in Edit | trueline_edit |
103
- |---|---|---|
104
- | Old text echoed (output tokens) | ~225 | 0 |
105
- | New text (output tokens) | ~225 | ~225 |
106
- | Range/checksum overhead | 0 | ~13 |
107
- | **Total output tokens** | **~470** | **~263** |
108
- | **Savings** | | **44%** |
109
-
110
- Output tokens are the most expensive token class. Cutting them by 44%
111
- on every edit adds up fast.
112
-
113
- ### Targeted reads save more input tokens
82
+ ### Write less: `trueline_edit`
114
83
 
115
- `trueline_read` supports multiple disjoint ranges in a single call.
116
- Instead of re-reading a 2000-line file to edit two distant sections,
117
- the agent reads only the ranges it needs:
84
+ The built-in `Edit` makes the model echo back the old text being
85
+ replaced. `trueline_edit` replaces that with a compact line-range
86
+ reference: the model only outputs the new content.
118
87
 
119
- ```
120
- trueline_read(file_path: "big-file.ts", ranges: [{start: 45, end: 60}, {start: 200, end: 215}])
121
- ```
88
+ For a typical 15-line edit, that's **44% fewer output tokens**. Output
89
+ tokens are the most expensive token class, so this adds up fast.
122
90
 
123
- 30 lines instead of 2000 with separate checksums for each range.
91
+ Multiple edits can be batched in a single call and applied atomically.
124
92
 
125
- ### Hash verification catches mistakes
93
+ ### Never corrupt: hash verification
126
94
 
127
95
  Every line from `trueline_read` carries a content hash. Every edit must
128
96
  present those hashes back, proving the agent is working against the
129
- file's actual content:
130
-
131
- ```
132
- 1:bx|import { Server } from "@modelcontextprotocol/sdk/server/index.js";
133
- 2:dd|
134
- 3:ew|const server = new Server({ name: "trueline-mcp", version: "0.1.0" });
135
-
136
- checksum: 1-3:8a64a3f7
137
- ```
138
-
139
- If anything changed since the read — concurrent edits, model
140
- hallucination, stale context — the edit is rejected before any bytes
141
- hit disk. Three layers of protection:
142
-
143
- | Layer | What it catches |
144
- |-------|----------------|
145
- | Per-line hash | Changed content at edit boundaries |
146
- | Range checksum | Any change within the read window |
147
- | mtime guard | Concurrent modification by another process |
97
+ file's actual content. If anything changed (concurrent edits, model
98
+ hallucination, stale context) the edit is rejected before any bytes hit
99
+ disk.
148
100
 
149
101
  No more silent corruption. No more ambiguous string matches.
150
102
 
151
- ### Batch edits in one call
152
-
153
- The built-in `Edit` handles one replacement per call. `trueline_edit`
154
- accepts an array of edits applied atomically, cutting tool-call
155
- overhead for multi-site changes.
156
-
157
- ## Workflow
158
-
159
- ```
160
- trueline_outline (navigate)
161
- → trueline_read (targeted ranges)
162
- → trueline_diff (preview) [optional]
163
- → trueline_edit (apply)
164
- ```
165
-
166
- A `SessionStart` hook injects instructions directing the agent to use
167
- trueline tools. A `PreToolUse` hook blocks the built-in `Edit` tool and
168
- redirects to the trueline workflow. On other platforms, equivalent hooks
169
- are available via the `trueline-hook` CLI dispatcher — see
170
- [INSTALL.md](INSTALL.md).
171
-
172
- ## Path access
173
-
174
- By default, trueline tools can access files inside the project directory.
175
- When running under Claude Code, `~/.claude/` is also allowed (it stores
176
- plans, memory, and settings). To allow additional directories on any
177
- platform, set `TRUELINE_ALLOWED_DIRS` to a colon-separated list of paths
178
- (semicolon-separated on Windows).
179
-
180
- ## Runtime Engine Selection
181
-
182
- trueline runs on Bun, Deno, and Node.js. It will use whichever runtime is
183
- available on your system, but the choice matters for performance. Bun is the
184
- fastest by a comfortable margin, followed by Deno, with Node.js coming in last.
185
- If you have the option, install [Bun](https://bun.sh) — you'll notice the
186
- difference on large files and batch edits.
187
-
188
- ## Benchmarks
189
-
190
- Measured on Apple M4 Max (48 GB) with Bun 1.3.9. Edit times include a
191
- fresh range-read for checksum verification.
192
-
193
- | File size | Read (full) | Read (100 lines) | Edit (11-line replace) |
194
- |-----------|-------------|-------------------|------------------------|
195
- | 10 KB / 100 lines | 0.4 ms | 0.3 ms | 0.6 ms |
196
- | 100 KB / 1K lines | 1.3 ms | 0.5 ms | 1.9 ms |
197
- | 1 MB / 10K lines | 2.1 ms | 3.7 ms | 15.4 ms |
198
- | 5 MB / 50K lines | 2.1 ms | 17.8 ms | 75.5 ms |
199
- | 10 MB / 100K lines | 2.2 ms | 37.7 ms | 152 ms |
103
+ ## Design
200
104
 
201
- Full reads cap at ~2 ms for files above 1 MB because the 2,000-line output
202
- limit triggers early truncation. Range reads and edits scale linearly with
203
- file size since they stream the entire file.
105
+ See [DESIGN.md](DESIGN.md) for the protocol specification, hash
106
+ algorithm details, streaming architecture, and security model.
204
107
 
205
108
  ## Development
206
109
 
package/dist/server.js CHANGED
@@ -9391,7 +9391,7 @@ ${JSON.stringify(symbolNames, null, 2)}`);
9391
9391
  });
9392
9392
 
9393
9393
  // src/server.ts
9394
- import { mkdir, realpath as realpath2 } from "node:fs/promises";
9394
+ import { mkdir as mkdir2, realpath as realpath3 } from "node:fs/promises";
9395
9395
  import { homedir as homedir2 } from "node:os";
9396
9396
  import { delimiter, join as join2 } from "node:path";
9397
9397
 
@@ -22353,7 +22353,7 @@ class StdioServerTransport {
22353
22353
  // package.json
22354
22354
  var package_default = {
22355
22355
  name: "trueline-mcp",
22356
- version: "2.2.0",
22356
+ version: "2.4.0",
22357
22357
  type: "module",
22358
22358
  description: "Truth-verified file editing for AI coding agents via MCP",
22359
22359
  license: "Apache-2.0",
@@ -22396,7 +22396,8 @@ var package_default = {
22396
22396
  typecheck: "bun x tsc --noEmit",
22397
22397
  test: "bun test",
22398
22398
  lint: "bunx biome ci .",
22399
- "lint:fix": "bunx biome check --write ."
22399
+ "lint:fix": "bunx biome check --write .",
22400
+ benchmark: "bun run benchmarks/token-benchmark.ts"
22400
22401
  },
22401
22402
  dependencies: {
22402
22403
  "@modelcontextprotocol/sdk": "^1.26.0",
@@ -23399,6 +23400,7 @@ function editSummary(ops) {
23399
23400
  // src/tools/read.ts
23400
23401
  async function handleRead(params) {
23401
23402
  const { file_path, start_line, end_line, projectDir, allowedDirs } = params;
23403
+ const includeHashes = params.hashes !== false;
23402
23404
  const validated = await validatePath(file_path, "Read", projectDir, allowedDirs);
23403
23405
  if (!validated.ok)
23404
23406
  return validated.error;
@@ -23469,7 +23471,7 @@ checksum: ${formatChecksum(rangeFirstLine, rangeLastLine, rangeChecksumHash)}
23469
23471
  if (rangeFirstLine === 0)
23470
23472
  rangeFirstLine = lineNumber;
23471
23473
  const h = fnv1aHashBytes(lineBytes, 0, lineBytes.length);
23472
- const prefix = Buffer.from(`${lineNumber}:${hashToLetters(h)}|`);
23474
+ const prefix = includeHashes ? Buffer.from(`${lineNumber}:${hashToLetters(h)}|`) : Buffer.from(`${lineNumber}|`);
23473
23475
  const lineLen = prefix.length + lineBytes.length + 1;
23474
23476
  outputLines++;
23475
23477
  if (outputLines > MAX_OUTPUT_LINES || outputLen + lineLen > MAX_OUTPUT_BYTES) {
@@ -23559,9 +23561,24 @@ async function extractOutline(source, config2) {
23559
23561
  const lines = source.split(`
23560
23562
  `);
23561
23563
  const entries = [];
23562
- function firstLine(node) {
23563
- const line = lines[node.startPosition.row]?.trimEnd() ?? "";
23564
- return line.length > 150 ? `${line.slice(0, 147)}...` : line;
23564
+ function extractSignature(node) {
23565
+ const startRow = node.startPosition.row;
23566
+ const endRow = node.endPosition.row;
23567
+ const fl = lines[startRow]?.trimEnd() ?? "";
23568
+ if (startRow === endRow || fl.includes("{")) {
23569
+ return fl.length > 200 ? `${fl.slice(0, 197)}...` : fl;
23570
+ }
23571
+ const parts2 = [fl.trimEnd()];
23572
+ for (let row = startRow + 1;row <= Math.min(endRow, startRow + 20); row++) {
23573
+ const line = (lines[row] ?? "").trimEnd();
23574
+ parts2.push(line.trim());
23575
+ if (line.includes("{"))
23576
+ break;
23577
+ }
23578
+ let sig = parts2.join(" ").replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/,\s*\)/g, ")").replace(/\s*\{\s*$/, "");
23579
+ if (sig.length > 200)
23580
+ sig = `${sig.slice(0, 197)}...`;
23581
+ return sig;
23565
23582
  }
23566
23583
  let skipStart = -1;
23567
23584
  let skipEnd = -1;
@@ -23621,7 +23638,7 @@ async function extractOutline(source, config2) {
23621
23638
  endLine: node.endPosition.row + 1,
23622
23639
  depth,
23623
23640
  nodeType: node.type,
23624
- text: firstLine(node)
23641
+ text: extractSignature(node)
23625
23642
  });
23626
23643
  for (const child of node.children) {
23627
23644
  if (!child.isNamed)
@@ -23952,8 +23969,149 @@ async function handleOutline(params) {
23952
23969
  }
23953
23970
  }
23954
23971
 
23972
+ // src/tools/search.ts
23973
+ async function handleSearch(params) {
23974
+ const { file_path, pattern, projectDir, allowedDirs } = params;
23975
+ const contextLines = params.context_lines ?? 2;
23976
+ const maxMatches = params.max_matches ?? 10;
23977
+ const validated = await validatePath(file_path, "Read", projectDir, allowedDirs);
23978
+ if (!validated.ok)
23979
+ return validated.error;
23980
+ let regex;
23981
+ try {
23982
+ regex = new RegExp(pattern);
23983
+ } catch {
23984
+ return errorResult(`Invalid regex pattern: "${pattern}"`);
23985
+ }
23986
+ const { resolvedPath } = validated;
23987
+ const allLines = [];
23988
+ const matchIndices = [];
23989
+ try {
23990
+ for await (const { lineBytes, lineNumber } of splitLines(resolvedPath, { detectBinary: true })) {
23991
+ const h = fnv1aHashBytes(lineBytes, 0, lineBytes.length);
23992
+ const text = lineBytes.toString("utf-8");
23993
+ const isMatch = regex.test(text);
23994
+ allLines.push({ lineNumber, content: lineBytes, hash: h, isMatch });
23995
+ if (isMatch)
23996
+ matchIndices.push(allLines.length - 1);
23997
+ }
23998
+ } catch (err2) {
23999
+ if (err2 instanceof Error && err2.message.includes("binary")) {
24000
+ return errorResult(`"${file_path}" appears to be a binary file`);
24001
+ }
24002
+ throw err2;
24003
+ }
24004
+ if (matchIndices.length === 0) {
24005
+ return textResult(`No matches for pattern "${pattern}" in ${file_path}`);
24006
+ }
24007
+ const totalMatches = matchIndices.length;
24008
+ const cappedIndices = matchIndices.slice(0, maxMatches);
24009
+ const ranges = [];
24010
+ for (const idx of cappedIndices) {
24011
+ const start2 = Math.max(0, idx - contextLines);
24012
+ const end = Math.min(allLines.length - 1, idx + contextLines);
24013
+ if (ranges.length > 0 && start2 <= ranges[ranges.length - 1].end + 1) {
24014
+ ranges[ranges.length - 1].end = end;
24015
+ } else {
24016
+ ranges.push({ start: start2, end });
24017
+ }
24018
+ }
24019
+ const parts2 = [];
24020
+ for (let i2 = 0;i2 < ranges.length; i2++) {
24021
+ const range = ranges[i2];
24022
+ let checksumHash = FNV_OFFSET_BASIS;
24023
+ let firstLine = 0;
24024
+ let lastLine = 0;
24025
+ if (i2 > 0)
24026
+ parts2.push("");
24027
+ for (let idx = range.start;idx <= range.end; idx++) {
24028
+ const line = allLines[idx];
24029
+ if (firstLine === 0)
24030
+ firstLine = line.lineNumber;
24031
+ lastLine = line.lineNumber;
24032
+ checksumHash = foldHash(checksumHash, line.hash);
24033
+ const marker = line.isMatch ? " ← match" : "";
24034
+ parts2.push(`${line.lineNumber}:${hashToLetters(line.hash)}|${line.content.toString("utf-8")}${marker}`);
24035
+ }
24036
+ parts2.push("");
24037
+ parts2.push(`checksum: ${formatChecksum(firstLine, lastLine, checksumHash)}`);
24038
+ }
24039
+ if (totalMatches > maxMatches) {
24040
+ parts2.push("");
24041
+ parts2.push(`(showing ${maxMatches} of ${totalMatches} matches — increase max_matches to see more)`);
24042
+ }
24043
+ return textResult(parts2.join(`
24044
+ `));
24045
+ }
24046
+
24047
+ // src/tools/write.ts
24048
+ import { dirname as dirname3, resolve as resolve5, sep as sep2 } from "node:path";
24049
+ import { mkdir, realpath as realpath2, stat as stat4, writeFile } from "node:fs/promises";
24050
+ async function validateWritePath(file_path, projectDir, allowedDirs = []) {
24051
+ const resolvedPath = file_path.startsWith("/") ? file_path : resolve5(projectDir ?? process.cwd(), file_path);
24052
+ let realPath;
24053
+ let exists = false;
24054
+ try {
24055
+ realPath = await realpath2(resolvedPath);
24056
+ exists = true;
24057
+ const fileStat = await stat4(realPath);
24058
+ if (!fileStat.isFile()) {
24059
+ return { ok: false, error: errorResult(`"${file_path}" is not a regular file`) };
24060
+ }
24061
+ } catch {
24062
+ realPath = resolvedPath;
24063
+ let ancestor = dirname3(resolvedPath);
24064
+ let realAncestor;
24065
+ while (ancestor !== dirname3(ancestor)) {
24066
+ try {
24067
+ realAncestor = await realpath2(ancestor);
24068
+ break;
24069
+ } catch {
24070
+ ancestor = dirname3(ancestor);
24071
+ }
24072
+ }
24073
+ if (!realAncestor) {
24074
+ return { ok: false, error: errorResult(`No accessible ancestor directory for "${file_path}"`) };
24075
+ }
24076
+ const tail = resolvedPath.slice(ancestor.length);
24077
+ realPath = realAncestor + tail;
24078
+ }
24079
+ let realBase;
24080
+ try {
24081
+ realBase = await realpath2(projectDir ? projectDir : process.cwd());
24082
+ } catch {
24083
+ return { ok: false, error: errorResult("Project directory not found or inaccessible") };
24084
+ }
24085
+ const resolvedAllowed = await Promise.all(allowedDirs.map((d) => realpath2(d).catch(() => d)));
24086
+ const allBases = [realBase, ...resolvedAllowed];
24087
+ const isContained = allBases.some((base) => realPath === base || realPath.startsWith(base + sep2));
24088
+ if (!isContained) {
24089
+ return { ok: false, error: errorResult(`Access denied: "${file_path}" is outside the project directory`) };
24090
+ }
24091
+ return { ok: true, resolvedPath: realPath, exists };
24092
+ }
24093
+ async function handleWrite(params) {
24094
+ const { file_path, content, projectDir, allowedDirs } = params;
24095
+ const createDirs = params.create_directories !== false;
24096
+ const validated = await validateWritePath(file_path, projectDir, allowedDirs);
24097
+ if (!validated.ok)
24098
+ return validated.error;
24099
+ const { resolvedPath } = validated;
24100
+ if (createDirs) {
24101
+ await mkdir(dirname3(resolvedPath), { recursive: true });
24102
+ }
24103
+ await writeFile(resolvedPath, content, "utf-8");
24104
+ const readResult = await handleRead({ file_path: resolvedPath, projectDir, allowedDirs });
24105
+ const checksumMatch = readResult.content[0].text.match(/checksum: (\S+)/);
24106
+ const checksum = checksumMatch ? checksumMatch[1] : "0-0:00000000";
24107
+ const verb = validated.exists ? "overwritten" : "created";
24108
+ return textResult(`File ${verb}: ${file_path}
24109
+
24110
+ checksum: ${checksum}`);
24111
+ }
24112
+
23955
24113
  // src/update-check.ts
23956
- import { readFile as readFile2, writeFile } from "node:fs/promises";
24114
+ import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
23957
24115
  import { join } from "node:path";
23958
24116
  import { tmpdir } from "node:os";
23959
24117
  var PACKAGE_NAME = "trueline-mcp";
@@ -23969,7 +24127,7 @@ async function readCache() {
23969
24127
  }
23970
24128
  }
23971
24129
  async function writeCache(entry) {
23972
- await writeFile(CACHE_FILE, JSON.stringify(entry)).catch(() => {});
24130
+ await writeFile2(CACHE_FILE, JSON.stringify(entry)).catch(() => {});
23973
24131
  }
23974
24132
  async function fetchLatestVersion() {
23975
24133
  try {
@@ -24028,20 +24186,20 @@ var server = new McpServer({
24028
24186
  version: VERSION2
24029
24187
  });
24030
24188
  var rawProjectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
24031
- var projectDir = await realpath2(rawProjectDir).catch(() => rawProjectDir);
24189
+ var projectDir = await realpath3(rawProjectDir).catch(() => rawProjectDir);
24032
24190
  async function resolveAllowedDirs() {
24033
24191
  const dirs = [];
24034
24192
  if (process.env.CLAUDE_PROJECT_DIR) {
24035
24193
  const claudeDir = join2(homedir2(), ".claude");
24036
- await mkdir(claudeDir, { recursive: true }).catch(() => {});
24037
- const realClaudeDir = await realpath2(claudeDir).catch(() => null);
24194
+ await mkdir2(claudeDir, { recursive: true }).catch(() => {});
24195
+ const realClaudeDir = await realpath3(claudeDir).catch(() => null);
24038
24196
  if (realClaudeDir)
24039
24197
  dirs.push(realClaudeDir);
24040
24198
  }
24041
24199
  const extra = process.env.TRUELINE_ALLOWED_DIRS;
24042
24200
  if (extra) {
24043
24201
  for (const raw of extra.split(delimiter).filter(Boolean)) {
24044
- const resolved = await realpath2(raw).catch(() => null);
24202
+ const resolved = await realpath3(raw).catch(() => null);
24045
24203
  if (resolved)
24046
24204
  dirs.push(resolved);
24047
24205
  }
@@ -24057,7 +24215,8 @@ server.registerTool("trueline_read", {
24057
24215
  start: exports_external.number().int().positive().describe("First line to read (1-based).").optional(),
24058
24216
  end: exports_external.number().int().positive().describe("Last line to read (1-based, inclusive).").optional()
24059
24217
  })).describe("Line ranges to read. Omit to read the whole file. Example: [{start: 10, end: 25}] or [{start: 1, end: 50}, {start: 200, end: 220}] for disjoint ranges. Each range gets its own checksum.")).optional(),
24060
- encoding: exports_external.string().describe("File encoding. Defaults to utf-8. Supported: utf-8, ascii, latin1.").optional()
24218
+ encoding: exports_external.string().describe("File encoding. Defaults to utf-8. Supported: utf-8, ascii, latin1.").optional(),
24219
+ hashes: exports_external.boolean().describe("Include per-line hashes in output. Defaults to true. Set to false for exploratory reads where you don't plan to edit — saves tokens. Checksums are always included.").optional()
24061
24220
  })
24062
24221
  }, async (params) => {
24063
24222
  return handleRead({ ...params, projectDir, allowedDirs });
@@ -24098,6 +24257,27 @@ server.registerTool("trueline_outline", {
24098
24257
  }, async (params) => {
24099
24258
  return handleOutline({ ...params, projectDir, allowedDirs });
24100
24259
  });
24260
+ server.registerTool("trueline_search", {
24261
+ description: "Search a file by regex pattern. Returns matching lines with context, per-line hashes, and checksums — " + "ready for immediate editing. Use instead of outline+read when you know what pattern to look for.",
24262
+ inputSchema: exports_external.object({
24263
+ file_path: exports_external.string(),
24264
+ pattern: exports_external.string().describe("Regex pattern to search for (line-by-line matching)."),
24265
+ context_lines: exports_external.number().int().min(0).describe("Lines of context above/below each match. Default: 2.").optional(),
24266
+ max_matches: exports_external.number().int().positive().describe("Maximum number of matches to return. Default: 10.").optional()
24267
+ })
24268
+ }, async (params) => {
24269
+ return handleSearch({ ...params, projectDir, allowedDirs });
24270
+ });
24271
+ server.registerTool("trueline_write", {
24272
+ description: "Create or overwrite a file. Returns a checksum of the written content for verification. " + "To edit afterward, call trueline_read first to get per-line hashes.",
24273
+ inputSchema: exports_external.object({
24274
+ file_path: exports_external.string(),
24275
+ content: exports_external.string().describe("The full file content to write."),
24276
+ create_directories: exports_external.boolean().describe("Create parent directories if they don't exist. Default: true.").optional()
24277
+ })
24278
+ }, async (params) => {
24279
+ return handleWrite({ ...params, projectDir, allowedDirs });
24280
+ });
24101
24281
  var transport = new StdioServerTransport;
24102
24282
  try {
24103
24283
  await server.connect(transport);
@@ -11,6 +11,9 @@ const PLATFORM_RULES = {
11
11
  readAdvice:
12
12
  "Never use the built-in Read tool \u2014 use trueline_read instead. " +
13
13
  "trueline_read returns per-line hashes and checksums needed for trueline_edit.",
14
+ writeAdvice:
15
+ "Never use the built-in Write tool for files in the project directory \u2014 use trueline_write instead. " +
16
+ "trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
14
17
  subagentRule: true,
15
18
  },
16
19
  "gemini-cli": {
@@ -18,6 +21,9 @@ const PLATFORM_RULES = {
18
21
  readAdvice:
19
22
  "Never use read_file or read_many_files \u2014 use trueline_read instead. " +
20
23
  "trueline_read returns per-line hashes and checksums needed for trueline_edit.",
24
+ writeAdvice:
25
+ "Never use write_file for files in the project directory \u2014 use trueline_write instead. " +
26
+ "trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
21
27
  subagentRule: false,
22
28
  },
23
29
  "vscode-copilot": {
@@ -25,6 +31,9 @@ const PLATFORM_RULES = {
25
31
  readAdvice:
26
32
  "Never use the built-in Read tool \u2014 use trueline_read instead. " +
27
33
  "trueline_read returns per-line hashes and checksums needed for trueline_edit.",
34
+ writeAdvice:
35
+ "Never use the built-in Write tool for files in the project directory \u2014 use trueline_write instead. " +
36
+ "trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
28
37
  subagentRule: false,
29
38
  },
30
39
  opencode: {
@@ -32,6 +41,9 @@ const PLATFORM_RULES = {
32
41
  readAdvice:
33
42
  "Never use the built-in view tool \u2014 use trueline_read instead. " +
34
43
  "trueline_read returns per-line hashes and checksums needed for trueline_edit.",
44
+ writeAdvice:
45
+ "Never use the built-in write tool for files in the project directory \u2014 use trueline_write instead. " +
46
+ "trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
35
47
  subagentRule: false,
36
48
  },
37
49
  codex: {
@@ -39,6 +51,9 @@ const PLATFORM_RULES = {
39
51
  readAdvice:
40
52
  "Never use read_file or shell with cat/head/tail \u2014 use trueline_read instead. " +
41
53
  "trueline_read returns per-line hashes and checksums needed for trueline_edit.",
54
+ writeAdvice:
55
+ "Never use shell with echo/cat redirection for files in the project directory \u2014 use trueline_write instead. " +
56
+ "trueline_write returns a checksum for verification. To edit afterward, call trueline_read first.",
42
57
  subagentRule: false,
43
58
  },
44
59
  };
@@ -58,17 +73,22 @@ export function getInstructions(platform = "claude-code") {
58
73
 
59
74
  return `<trueline_mcp_instructions>
60
75
  <tools>
61
- <tool name="trueline_read">Read a file; returns per-line hashes and a checksum per range. Supports multiple disjoint ranges in one call. Call before editing.</tool>
76
+ <tool name="trueline_read">Read a file; returns per-line hashes and a checksum per range. Supports multiple disjoint ranges in one call. Call before editing. Pass hashes=false for exploratory reads where you don't plan to edit — saves tokens while keeping checksums.</tool>
62
77
  <tool name="trueline_edit">Edit a file with hash verification. Replaces the built-in Edit tool, which is blocked. Each edit needs: checksum (from trueline_read for the covering range), range (startLine:hash..endLine:hash or +startLine:hash for insert-after), content (replacement lines as newline-separated string; empty string to delete). Pass all changes to the same file in the edits array.</tool>
63
78
  <tool name="trueline_diff">Preview edits as a unified diff without writing to disk.</tool>
64
79
  <tool name="trueline_outline">Get a compact structural outline of a source file (functions, classes, types, etc.) without reading full content. Often sufficient on its own for navigation and understanding. Use before trueline_read to identify the right line ranges when you do need to read.</tool>
80
+ <tool name="trueline_search">Search a file by regex. Returns matching lines with surrounding context, per-line hashes, and checksums \u2014 output is edit-ready (pass checksums directly to trueline_edit). Preferred over Grep for single-file searches because results include hashes for immediate editing. Preferred over outline+read when you know a pattern to search for.</tool>
81
+ <tool name="trueline_write">Create or overwrite a file. Returns a checksum for verification. To edit afterward, call trueline_read first to get per-line hashes. Use instead of the built-in Write tool for files in the project directory.</tool>
65
82
  </tools>
66
- <workflow>trueline_outline (navigate / understand) \u2192 trueline_read (targeted ranges, only if needed) \u2192 trueline_diff (optional) \u2192 trueline_edit</workflow>
83
+ <workflow>trueline_outline (navigate / understand) trueline_read (targeted ranges, only if needed) trueline_diff (optional) trueline_edit</workflow>
84
+ <workflow>trueline_search (find + read in one step) → trueline_edit (edit directly from search results, no re-read needed)</workflow>
67
85
  <rules>${editRule}
68
- <rule>${rules.readAdvice}</rule>${subagentRule}
86
+ <rule>${rules.readAdvice}</rule>
87
+ <rule>${rules.writeAdvice}</rule>${subagentRule}
69
88
  <rule>trueline_outline is often enough by itself for questions about file structure, purpose, or navigation. Only call trueline_read when you actually need the source code (e.g. to edit, debug, or understand implementation details).</rule>
70
89
  <rule>After using trueline_outline, if you do need to read, use its line numbers to read only the specific ranges you need \u2014 do NOT read the entire file.</rule>
71
90
  <rule>Only read a full file (no ranges) when you have not used trueline_outline and the file is short, or you genuinely need every line.</rule>
72
- </rules>
91
+ <rule>When you need to find a pattern in a specific file (e.g. to locate code before editing, find usages within a file, or search for a string), use trueline_search instead of Grep or outline+read. trueline_search returns hashes and checksums so you can edit immediately without a separate trueline_read call.</rule>
92
+ <rule>When you need to find a pattern across many files, use Grep to identify the files, then use trueline_search on individual files you need to edit.</rule>
73
93
  </trueline_mcp_instructions>`;
74
94
  }
@@ -6,12 +6,15 @@
6
6
  // block/approve decisions. Returns normalized {action, reason} objects
7
7
  // that platform-specific formatters translate to the right JSON shape.
8
8
 
9
+ import { stat } from "node:fs/promises";
10
+
9
11
  // Maps platform-specific built-in tool names to canonical names.
10
12
  const TOOL_ALIASES = {
11
13
  // Gemini CLI
12
14
  read_file: "Read",
13
15
  read_many_files: "Read",
14
16
  edit_file: "Edit",
17
+ write_file: "Write",
15
18
  run_shell_command: "Bash",
16
19
  // OpenCode
17
20
  view: "Read",
@@ -88,5 +91,30 @@ export async function routePreToolUse(toolName, toolInput, canAccessFn) {
88
91
  return null;
89
92
  }
90
93
 
94
+ if (canonical === "Write") {
95
+ if (typeof filePath === "string") {
96
+ const canWrite = await canAccessFn(filePath, "Edit");
97
+ if (canWrite) {
98
+ // Fall through to built-in Write for non-regular files (directories,
99
+ // devices, etc.) that trueline_write would reject.
100
+ try {
101
+ const s = await stat(filePath);
102
+ if (!s.isFile()) return null;
103
+ } catch {
104
+ // File doesn't exist yet — trueline_write can handle creation.
105
+ }
106
+ return {
107
+ action: "block",
108
+ reason:
109
+ "<trueline_redirect>" +
110
+ "Use trueline_write instead of Write for files in the project directory. " +
111
+ "trueline_write returns a checksum for verification." +
112
+ "</trueline_redirect>",
113
+ };
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
91
119
  return null;
92
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trueline-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Truth-verified file editing for AI coding agents via MCP",
6
6
  "license": "Apache-2.0",
@@ -43,7 +43,8 @@
43
43
  "typecheck": "bun x tsc --noEmit",
44
44
  "test": "bun test",
45
45
  "lint": "bunx biome ci .",
46
- "lint:fix": "bunx biome check --write ."
46
+ "lint:fix": "bunx biome check --write .",
47
+ "benchmark": "bun run benchmarks/token-benchmark.ts"
47
48
  },
48
49
  "dependencies": {
49
50
  "@modelcontextprotocol/sdk": "^1.26.0",