@nxuss/lemma 1.3.3 β†’ 1.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.
Files changed (56) hide show
  1. package/README.md +31 -2
  2. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  3. package/dist/cjs/mcp/tools.js +112 -71
  4. package/dist/cjs/mcp/tools.js.map +1 -1
  5. package/dist/cjs/subconscious/TheBrainV2.d.ts +4 -1
  6. package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
  7. package/dist/cjs/subconscious/TheBrainV2.js +49 -9
  8. package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
  9. package/dist/cjs/utils/PatchMatcher.d.ts +17 -0
  10. package/dist/cjs/utils/PatchMatcher.d.ts.map +1 -0
  11. package/dist/cjs/utils/PatchMatcher.js +210 -0
  12. package/dist/cjs/utils/PatchMatcher.js.map +1 -0
  13. package/dist/cjs/utils/ReadWorkspaceCache.d.ts +9 -0
  14. package/dist/cjs/utils/ReadWorkspaceCache.d.ts.map +1 -0
  15. package/dist/cjs/utils/ReadWorkspaceCache.js +31 -0
  16. package/dist/cjs/utils/ReadWorkspaceCache.js.map +1 -0
  17. package/dist/cjs/utils/SearchCache.d.ts +7 -0
  18. package/dist/cjs/utils/SearchCache.d.ts.map +1 -0
  19. package/dist/cjs/utils/SearchCache.js +75 -0
  20. package/dist/cjs/utils/SearchCache.js.map +1 -0
  21. package/dist/cjs/utils/StructuralSearch.d.ts +18 -0
  22. package/dist/cjs/utils/StructuralSearch.d.ts.map +1 -0
  23. package/dist/cjs/utils/StructuralSearch.js +190 -0
  24. package/dist/cjs/utils/StructuralSearch.js.map +1 -0
  25. package/dist/cjs/utils/WorkspaceSearch.d.ts +20 -0
  26. package/dist/cjs/utils/WorkspaceSearch.d.ts.map +1 -0
  27. package/dist/cjs/utils/WorkspaceSearch.js +0 -0
  28. package/dist/cjs/utils/WorkspaceSearch.js.map +1 -0
  29. package/dist/esm/mcp/tools.d.ts.map +1 -1
  30. package/dist/esm/mcp/tools.js +112 -71
  31. package/dist/esm/mcp/tools.js.map +1 -1
  32. package/dist/esm/subconscious/TheBrainV2.d.ts +4 -1
  33. package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
  34. package/dist/esm/subconscious/TheBrainV2.js +49 -9
  35. package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
  36. package/dist/esm/utils/PatchMatcher.d.ts +17 -0
  37. package/dist/esm/utils/PatchMatcher.d.ts.map +1 -0
  38. package/dist/esm/utils/PatchMatcher.js +205 -0
  39. package/dist/esm/utils/PatchMatcher.js.map +1 -0
  40. package/dist/esm/utils/ReadWorkspaceCache.d.ts +9 -0
  41. package/dist/esm/utils/ReadWorkspaceCache.d.ts.map +1 -0
  42. package/dist/esm/utils/ReadWorkspaceCache.js +24 -0
  43. package/dist/esm/utils/ReadWorkspaceCache.js.map +1 -0
  44. package/dist/esm/utils/SearchCache.d.ts +7 -0
  45. package/dist/esm/utils/SearchCache.d.ts.map +1 -0
  46. package/dist/esm/utils/SearchCache.js +67 -0
  47. package/dist/esm/utils/SearchCache.js.map +1 -0
  48. package/dist/esm/utils/StructuralSearch.d.ts +18 -0
  49. package/dist/esm/utils/StructuralSearch.d.ts.map +1 -0
  50. package/dist/esm/utils/StructuralSearch.js +150 -0
  51. package/dist/esm/utils/StructuralSearch.js.map +1 -0
  52. package/dist/esm/utils/WorkspaceSearch.d.ts +20 -0
  53. package/dist/esm/utils/WorkspaceSearch.d.ts.map +1 -0
  54. package/dist/esm/utils/WorkspaceSearch.js +0 -0
  55. package/dist/esm/utils/WorkspaceSearch.js.map +1 -0
  56. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🧠 Lemma v1.3.3
1
+ # 🧠 Lemma v1.4.0
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@nxuss/lemma.svg?color=orange&style=flat-square)](https://www.npmjs.com/package/@nxuss/lemma)
4
4
  [![License](https://img.shields.io/npm/l/@nxuss/lemma.svg?color=blue&style=flat-square)](https://github.com/Nxusbets/lemma/blob/main/LICENSE)
@@ -23,7 +23,36 @@ Lemma is a high-performance orchestration layer that sits between your developme
23
23
 
24
24
  ## πŸ“‹ Changelog
25
25
 
26
- ### v1.3.0 β€” Verifiable Caching *(NEW)*
26
+ ### v1.4.0 β€” Correctness-first patching, search, and memory freshness *(NEW)*
27
+
28
+ An audit of the everyday MCP path β€” read a file, search the workspace, patch it, remember the
29
+ answer β€” found the same failure mode repeating: a tool that looked done but silently degraded
30
+ correctness or wasted tokens the moment reality diverged from the happy path. This release fixes
31
+ that path end to end.
32
+
33
+ | Tool | What changed | Why |
34
+ | :--- | :--- | :--- |
35
+ | `apply_workspace_patch` | Cascading match: exact → whitespace-normalized → indent-anchored → fuzzy (similarity-scored, with an anti-tie margin). On total failure, the error now includes a line-diff against the closest candidate block instead of a generic "not found". | A patch built from a `read_workspace_file(compact:true)` view (comments/imports stripped, whitespace collapsed) could never byte-match the file on disk — a guaranteed failure on the most common read→edit flow, forcing an expensive full re-read every time. |
36
+ | `search_workspace` | Respects `.gitignore`, skips binaries and files over 500KB, supports comma-separated multi-extension filters, caps results with `maxResults`, and groups matches by file (capped per file) instead of one repeated-path line per hit. Code files (`.ts/.tsx/.js/.jsx`) get symbol-level grouping β€” matches inside the same function/class collapse into one entry with its signature and full line list instead of raw duplicate lines. Repeating an identical query while the workspace is unchanged (tracked via a cheap mtime/size fingerprint, no content re-reads) returns the cached result instantly. | A single broad query could read 300+ files (2.6MB+) unfiltered and return 15,000+ tokens of flat, path-repeated text β€” often more expensive than just reading the files directly. |
37
+ | `read_workspace_file` | Tracks the content hash last sent for each `(file, compact)` pair for the life of the MCP session. Re-reading an unchanged file returns a short `UNCHANGED` marker instead of the full content again; `force:true` bypasses it. | Re-reading the same file to verify an edit β€” one of the most common steps in a multi-turn coding session β€” was resending the full file every time, even when nothing had changed since the last read. |
38
+ | `store_memory` / `search_memory` | `store_memory` accepts an optional `filePaths` array and records a sha256 per file. `search_memory` now splits results into **fresh** (safe to reuse) and **stale** (a tracked file changed since it was stored) β€” a stale hit is never returned as if it were current. | The Brain's semantic match (BM25 + Jaccard) is intentionally fuzzy so differently-phrased questions still hit β€” but with no invalidation, a fuzzy hit could resurface a stale answer about code that had since changed, with no signal that it was stale. |
39
+
40
+ **Net effect**: the four tools an agent reaches for by default β€” read, search, patch, remember β€”
41
+ now fail closed (a diff instead of a dead-end error) and skip redundant work automatically instead
42
+ of requiring the calling model to remember to check first.
43
+
44
+ ### Files changed
45
+ - `src/utils/PatchMatcher.ts` *(new)* β€” cascading match strategies + line-diff generator
46
+ - `src/utils/WorkspaceSearch.ts` *(new)* β€” gitignore-aware, capped, grouped search
47
+ - `src/utils/StructuralSearch.ts` *(new)* β€” AST-based symbol grouping for code file matches
48
+ - `src/utils/SearchCache.ts` *(new)* β€” session-scoped, fingerprint-invalidated search cache
49
+ - `src/utils/ReadWorkspaceCache.ts` *(new)* β€” session-scoped read-dedup
50
+ - `src/subconscious/TheBrainV2.ts` β€” `fileHashes` on entries, `checkEntryFreshness`, `fresh`/`staleFiles` on search results
51
+ - `src/mcp/tools.ts` β€” wired all of the above into `apply_workspace_patch`, `search_workspace`, `read_workspace_file`, `store_memory`, `search_memory`
52
+
53
+ ---
54
+
55
+ ### v1.3.0 β€” Verifiable Caching
27
56
 
28
57
  Two new tools built around a single rule: don't ask the model to trust a similarity score or a
29
58
  savings estimate β€” give it something it can actually verify.
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../../src/mcp/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAwiCnE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC1B,MAAM,EAAE,IAAI,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAC1C,IAAI,CA2DN"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../../src/mcp/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AA+iCnE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC1B,MAAM,EAAE,IAAI,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAC1C,IAAI,CA2DN"}
@@ -72,6 +72,10 @@ const SurgicalASTInsert_1 = require("../utils/SurgicalASTInsert");
72
72
  const mcp_tools_1 = require("../infra/mcp-tools");
73
73
  const StateHashCache_1 = require("../utils/StateHashCache");
74
74
  const TokenReceipt_1 = require("../utils/TokenReceipt");
75
+ const PatchMatcher_1 = require("../utils/PatchMatcher");
76
+ const WorkspaceSearch_1 = require("../utils/WorkspaceSearch");
77
+ const ReadWorkspaceCache_1 = require("../utils/ReadWorkspaceCache");
78
+ const SearchCache_1 = require("../utils/SearchCache");
75
79
  // ── Pro License Gate ──────────────────────────────────────────────────────────
76
80
  const LICENSE_FILE = path_1.default.join(os_1.default.homedir(), '.lemma-cache', 'license.json');
77
81
  let _proStatusCache = null;
@@ -128,7 +132,7 @@ const toolDefinitions = [
128
132
  },
129
133
  {
130
134
  name: "search_memory",
131
- description: "Search Lemma's semantic memory (The Brain) before answering technical questions. Retrieves past solutions, fixes, and context from ALL your projects globally. If similarity > 50%, use this answer directly.",
135
+ description: "Search Lemma's semantic memory (The Brain) before investigating something from scratch β€” retrieves past solutions, fixes, and context from ALL your projects globally. Results are split into 'fresh' (safe to reuse β€” either untracked general knowledge, or every file it depended on still hashes the same) and 'stale' (a similar question was answered before, but a tracked file changed since β€” re-verify against current state before reusing). Never treat a stale result as current.",
132
136
  inputSchema: {
133
137
  type: "object",
134
138
  properties: {
@@ -140,13 +144,14 @@ const toolDefinitions = [
140
144
  },
141
145
  {
142
146
  name: "store_memory",
143
- description: "Persist a technical solution, bug fix, architecture decision, or key fact into Lemma's Brain.",
147
+ description: "Persist a technical solution, bug fix, architecture decision, or key fact into Lemma's Brain β€” so future questions on the same topic (even phrased differently) don't require re-investigating the repo. Pass filePaths for anything derived from specific files (e.g. 'where is X implemented', 'how does Y work') so the memory auto-invalidates the moment those files change, instead of risking a stale answer being reused.",
144
148
  inputSchema: {
145
149
  type: "object",
146
150
  properties: {
147
151
  query: { type: "string", description: "The natural language query key" },
148
152
  response: { type: "string", description: "The complete technical content to memorize" },
149
153
  provider: { type: "string", description: "Optional model provider name", default: "generic" },
154
+ filePaths: { type: "array", items: { type: "string" }, description: "Paths (relative to project root) this answer depends on. If any changes later, this memory is marked stale instead of being silently reused." },
150
155
  },
151
156
  required: ["query", "response"],
152
157
  },
@@ -175,12 +180,13 @@ const toolDefinitions = [
175
180
  },
176
181
  {
177
182
  name: "read_workspace_file",
178
- description: "Read a file inside the workspace. Automatically compresses comments/whitespace and scrubs API keys.",
183
+ description: "Read a file inside the workspace. Automatically compresses comments/whitespace and scrubs API keys. If this exact file (same compact mode) was already read this session and hasn't changed on disk, returns a short UNCHANGED marker instead of resending the full content β€” reuse your existing copy. Pass force:true to always get the full content.",
179
184
  inputSchema: {
180
185
  type: "object",
181
186
  properties: {
182
187
  filePath: { type: "string", description: "Path relative to the project root" },
183
188
  compact: { type: "boolean", description: "Whether to compress comments and whitespace", default: true },
189
+ force: { type: "boolean", description: "Bypass the unchanged-file short-circuit and always return full content", default: false },
184
190
  },
185
191
  required: ["filePath"],
186
192
  },
@@ -211,7 +217,7 @@ const toolDefinitions = [
211
217
  },
212
218
  {
213
219
  name: "apply_workspace_patch",
214
- description: "Apply a smart search-and-replace patch to an existing file. Safe against duplicate matches.",
220
+ description: "Apply a smart search-and-replace patch to an existing file. Falls back through whitespace-normalized, indent-anchored, and fuzzy matching if the exact block isn't found β€” so a searchContent built from a compacted read still applies. Safe against duplicate matches. On total failure, the error includes a diff against the closest candidate block.",
215
221
  inputSchema: {
216
222
  type: "object",
217
223
  properties: {
@@ -246,12 +252,13 @@ const toolDefinitions = [
246
252
  },
247
253
  {
248
254
  name: "search_workspace",
249
- description: "Perform a fast local text search (grep) across all files in the project workspace.",
255
+ description: "Perform a fast local text search (grep) across all files in the project workspace. Respects .gitignore, skips binaries and files over 500KB, groups results by file (capped per file), and stops at maxResults. For code files (.ts/.tsx/.js/.jsx), matches inside the same function/class/method are collapsed into one entry with its signature and line range instead of raw duplicate lines. Repeating an identical query while the workspace is unchanged returns the cached result instantly instead of re-scanning.",
250
256
  inputSchema: {
251
257
  type: "object",
252
258
  properties: {
253
259
  query: { type: "string", description: "The keyword or text pattern to search for" },
254
- extension: { type: "string", description: "Optional file extension filter (e.g., 'ts', 'json')" },
260
+ extension: { type: "string", description: "Optional file extension filter, comma-separated for multiple (e.g., 'ts,tsx')" },
261
+ maxResults: { type: "number", description: "Maximum total matches to return before truncating", default: 200 },
255
262
  },
256
263
  required: ["query"],
257
264
  },
@@ -1152,12 +1159,27 @@ async function handleSearchMemory(args) {
1152
1159
  if (results.length === 0) {
1153
1160
  return { content: [{ type: "text", text: "No relevant memories found in Lemma's Brain." }] };
1154
1161
  }
1155
- const formatted = results
1156
- .map((r, i) => {
1157
- return `Result ${i + 1} (Similarity: ${(r.similarity * 100).toFixed(1)}%)\nPrompt: ${r.query.substring(0, 300)}...\nResponse: ${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
1158
- })
1159
- .join("\n\n---\n\n");
1160
- return { content: [{ type: "text", text: `Lemma found ${results.length} memories:\n\n${formatted}` }] };
1162
+ const fresh = results.filter((r) => r.fresh);
1163
+ const stale = results.filter((r) => !r.fresh);
1164
+ const formatResult = (r, i) => `Result ${i + 1} (Similarity: ${(r.similarity * 100).toFixed(1)}%)\nPrompt: ${r.query.substring(0, 300)}...\nResponse: ${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
1165
+ if (fresh.length > 0) {
1166
+ // Only a fresh hit (or an untracked, purely conceptual entry) counts as a real
1167
+ // avoided re-investigation β€” this is the only case worth crediting as savings.
1168
+ const bestFresh = fresh[0];
1169
+ const tokensSaved = Math.max(100, Math.floor(String(bestFresh.response).length / 4));
1170
+ (0, reportSavings_1.reportSavings)({ source: "cache", tokens: tokensSaved, toolName: "search_memory", query: query.substring(0, 100) });
1171
+ }
1172
+ const parts = [];
1173
+ if (fresh.length > 0) {
1174
+ parts.push(`Lemma found ${fresh.length} fresh ${fresh.length === 1 ? "memory" : "memories"} (from cache β€” say so if you use this):\n\n${fresh.map(formatResult).join("\n\n---\n\n")}`);
1175
+ }
1176
+ if (stale.length > 0) {
1177
+ const staleText = stale
1178
+ .map((r, i) => `${formatResult(r, i)}\nSTALE β€” changed since stored: ${r.staleFiles.join(", ")}`)
1179
+ .join("\n\n---\n\n");
1180
+ parts.push(`${stale.length} more ${stale.length === 1 ? "memory is" : "memories are"} similar but STALE (underlying file(s) changed) β€” do not reuse verbatim, re-verify against current file state:\n\n${staleText}`);
1181
+ }
1182
+ return { content: [{ type: "text", text: parts.join("\n\n===\n\n") }] };
1161
1183
  }
1162
1184
  catch (e) {
1163
1185
  (0, utils_1.logError)("search_memory", e);
@@ -1168,11 +1190,12 @@ async function handleStoreMemory(args) {
1168
1190
  const query = args?.query;
1169
1191
  const responseText = args?.response;
1170
1192
  const provider = args?.provider || "generic";
1193
+ const filePaths = Array.isArray(args?.filePaths) ? args.filePaths : undefined;
1171
1194
  if (!query || !responseText)
1172
1195
  throw new Error("Query and response are required");
1173
1196
  try {
1174
1197
  const brain = (0, TheBrainV2_1.getBrain)();
1175
- const storeRes = brain.store(query, responseText, provider);
1198
+ const storeRes = brain.store(query, responseText, provider, 0.92, filePaths);
1176
1199
  const tokensSaved = Math.max(100, Math.floor(responseText.length / 4));
1177
1200
  (0, reportSavings_1.reportSavings)({
1178
1201
  source: "cache",
@@ -1180,7 +1203,8 @@ async function handleStoreMemory(args) {
1180
1203
  toolName: "store_memory",
1181
1204
  query: query.substring(0, 100),
1182
1205
  });
1183
- return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}` }] };
1206
+ const trackingNote = filePaths && filePaths.length > 0 ? ` Tracking freshness against ${filePaths.length} file(s) β€” this memory auto-invalidates if they change.` : "";
1207
+ return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}` }] };
1184
1208
  }
1185
1209
  catch (e) {
1186
1210
  (0, utils_1.logError)("store_memory", e);
@@ -1225,6 +1249,7 @@ async function handleAutoHeal(args) {
1225
1249
  async function handleReadWorkspaceFile(args) {
1226
1250
  const filePath = args?.filePath;
1227
1251
  const compact = args?.compact !== false;
1252
+ const force = !!args?.force;
1228
1253
  if (!filePath)
1229
1254
  throw new Error("filePath is required");
1230
1255
  const workspaceRoot = process.cwd();
@@ -1232,6 +1257,24 @@ async function handleReadWorkspaceFile(args) {
1232
1257
  const { resolved } = (0, utils_1.safeResolvePath)(workspaceRoot, filePath);
1233
1258
  let content = fs_1.default.readFileSync(resolved, "utf8");
1234
1259
  const originalSize = content.length;
1260
+ const dedup = (0, ReadWorkspaceCache_1.checkAlreadySent)(resolved, compact, content);
1261
+ if (!force && dedup.unchanged) {
1262
+ (0, reportSavings_1.reportSavings)({
1263
+ source: "contextSqueeze",
1264
+ charsBefore: originalSize,
1265
+ charsAfter: 0,
1266
+ toolName: "read_workspace_file",
1267
+ filePath,
1268
+ });
1269
+ return {
1270
+ content: [
1271
+ {
1272
+ type: "text",
1273
+ text: `UNCHANGED: ${filePath} is identical to what you already read this session (hash ${dedup.hash.slice(0, 12)}) β€” reuse your existing copy. Pass force:true to re-fetch anyway.`,
1274
+ },
1275
+ ],
1276
+ };
1277
+ }
1235
1278
  if (compact) {
1236
1279
  content = (0, ContextSqueezer_1.squeezeCode)(content);
1237
1280
  if (content.length < originalSize) {
@@ -1252,6 +1295,7 @@ async function handleReadWorkspaceFile(args) {
1252
1295
  catch (err) {
1253
1296
  (0, utils_1.logWarn)("read_workspace_file", "SemanticScrubber failed, returning raw content");
1254
1297
  }
1298
+ (0, ReadWorkspaceCache_1.recordSent)(resolved, compact, dedup.hash);
1255
1299
  return { content: [{ type: "text", text: content }] };
1256
1300
  }
1257
1301
  catch (err) {
@@ -1318,30 +1362,44 @@ async function handleApplyWorkspacePatch(args) {
1318
1362
  return { content: [{ type: "text", text: `Error: File ${filePath} does not exist.` }] };
1319
1363
  }
1320
1364
  const originalContent = fs_1.default.readFileSync(resolved, "utf8");
1321
- const index = originalContent.indexOf(searchContent);
1322
- if (index === -1) {
1323
- return {
1324
- content: [
1325
- {
1326
- type: "text",
1327
- text: `Error: Target search content not found in file. Make sure the search content matches exactly, including indentation, tabs, spaces, and newlines.`,
1328
- },
1329
- ],
1330
- };
1331
- }
1332
- if (originalContent.indexOf(searchContent, index + 1) !== -1) {
1365
+ const matchResult = (0, PatchMatcher_1.findMatch)(originalContent, searchContent);
1366
+ if (!Array.isArray(matchResult)) {
1367
+ if (matchResult.reason === "ambiguous") {
1368
+ return {
1369
+ content: [
1370
+ {
1371
+ type: "text",
1372
+ text: `Error: Multiple matches found for the search content. Please provide a larger search block with unique surrounding context lines to make it unambiguous.`,
1373
+ },
1374
+ ],
1375
+ };
1376
+ }
1377
+ if (matchResult.candidateDiff) {
1378
+ return {
1379
+ content: [
1380
+ {
1381
+ type: "text",
1382
+ text: `Error: No match found for the search content. Closest candidate starts at line ${matchResult.candidateStartLine} (- = your searchContent, + = actual file content):\n\n${matchResult.candidateDiff}`,
1383
+ },
1384
+ ],
1385
+ };
1386
+ }
1333
1387
  return {
1334
1388
  content: [
1335
1389
  {
1336
1390
  type: "text",
1337
- text: `Error: Multiple matches found for the search content. Please provide a larger search block with unique surrounding context lines to make it unambiguous.`,
1391
+ text: `Error: Target search content not found in file, and no similar block was found to diff against.`,
1338
1392
  },
1339
1393
  ],
1340
1394
  };
1341
1395
  }
1342
- const updatedContent = originalContent.replace(searchContent, replaceContent);
1343
- fs_1.default.writeFileSync(resolved, updatedContent, "utf8");
1344
- return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}` }] };
1396
+ const match = matchResult[0];
1397
+ const contentLines = originalContent.split("\n");
1398
+ const finalReplace = match.strategy === "exact" ? replaceContent : (0, PatchMatcher_1.reindentReplacement)(replaceContent, searchContent, contentLines[match.startLine]);
1399
+ const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
1400
+ fs_1.default.writeFileSync(resolved, updatedLines.join("\n"), "utf8");
1401
+ const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
1402
+ return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
1345
1403
  }
1346
1404
  catch (err) {
1347
1405
  (0, utils_1.logError)("apply_workspace_patch", err);
@@ -1442,53 +1500,28 @@ async function handleListWorkspaceDir(args) {
1442
1500
  return { content: [{ type: "text", text: `Error listing directory: ${err.message}` }] };
1443
1501
  }
1444
1502
  }
1445
- function searchDirRecursive(currentDir, relativePath, query, extFilter) {
1446
- try {
1447
- const entries = fs_1.default.readdirSync(currentDir, { withFileTypes: true });
1448
- let result = [];
1449
- for (const entry of entries) {
1450
- if (["node_modules", ".git", "dist", "chroma_data", ".lemma"].includes(entry.name))
1451
- continue;
1452
- const rel = relativePath ? path_1.default.join(relativePath, entry.name) : entry.name;
1453
- const full = path_1.default.join(currentDir, entry.name);
1454
- if (entry.isDirectory()) {
1455
- result.push(...searchDirRecursive(full, rel, query, extFilter));
1456
- }
1457
- else {
1458
- if (extFilter && !entry.name.endsWith(`.${extFilter}`))
1459
- continue;
1460
- try {
1461
- const content = fs_1.default.readFileSync(full, "utf8");
1462
- if (content.toLowerCase().includes(query.toLowerCase())) {
1463
- const lines = content.split("\n");
1464
- lines.forEach((lineText, idx) => {
1465
- if (lineText.toLowerCase().includes(query.toLowerCase())) {
1466
- result.push({ filePath: rel, line: idx + 1, text: lineText.trim() });
1467
- }
1468
- });
1469
- }
1470
- }
1471
- catch { }
1472
- }
1473
- }
1474
- return result;
1475
- }
1476
- catch {
1477
- return [];
1478
- }
1479
- }
1480
1503
  async function handleSearchWorkspace(args) {
1481
1504
  const query = args?.query;
1482
1505
  const extension = args?.extension;
1506
+ const maxResults = args?.maxResults || 200;
1483
1507
  if (!query)
1484
1508
  throw new Error("query is required");
1485
1509
  const workspaceRoot = process.cwd();
1486
1510
  try {
1487
- const results = searchDirRecursive(workspaceRoot, "", query, extension);
1488
- if (results.length === 0) {
1489
- return { content: [{ type: "text", text: `No matches found in the workspace for "${query}".` }] };
1490
- }
1491
- let formatted = results.map((r) => `[${r.filePath}:${r.line}] ${r.text}`).join("\n");
1511
+ const extensions = (0, WorkspaceSearch_1.parseExtensionFilter)(extension);
1512
+ const cached = (0, SearchCache_1.getCachedSearch)(workspaceRoot, query, extensions, maxResults);
1513
+ if (cached.text !== null) {
1514
+ (0, reportSavings_1.reportSavings)({ source: "cache", tokens: Math.floor(cached.text.length / 4), toolName: "search_workspace", query: query.substring(0, 100) });
1515
+ return { content: [{ type: "text", text: cached.text }] };
1516
+ }
1517
+ const { matches, truncated } = (0, WorkspaceSearch_1.searchWorkspace)(workspaceRoot, query, extensions, maxResults);
1518
+ if (matches.length === 0) {
1519
+ const emptyText = `No matches found in the workspace for "${query}".`;
1520
+ (0, SearchCache_1.setCachedSearch)(query, extensions, maxResults, cached.fingerprint, emptyText);
1521
+ return { content: [{ type: "text", text: emptyText }] };
1522
+ }
1523
+ const grouped = (0, WorkspaceSearch_1.groupSearchResults)(matches, truncated, undefined, workspaceRoot);
1524
+ let formatted = grouped.formatted;
1492
1525
  try {
1493
1526
  const scrubber = new SemanticScrubber_1.SemanticScrubber();
1494
1527
  const { maskedPrompt } = scrubber.mask(formatted);
@@ -1497,8 +1530,16 @@ async function handleSearchWorkspace(args) {
1497
1530
  catch (err) {
1498
1531
  (0, utils_1.logWarn)("search_workspace", "SemanticScrubber failed, returning raw results");
1499
1532
  }
1533
+ const truncationNote = grouped.truncated ? ` (truncated at ${maxResults} matches β€” narrow your query or extension filter for the rest)` : "";
1534
+ const resultText = `Found ${grouped.totalMatches} matches across ${grouped.filesMatched} files${truncationNote}:\n\n${formatted}`;
1535
+ (0, SearchCache_1.setCachedSearch)(query, extensions, maxResults, cached.fingerprint, resultText);
1500
1536
  return {
1501
- content: [{ type: "text", text: `Found ${results.length} matches:\n\n${formatted}` }],
1537
+ content: [
1538
+ {
1539
+ type: "text",
1540
+ text: resultText,
1541
+ },
1542
+ ],
1502
1543
  };
1503
1544
  }
1504
1545
  catch (err) {