@nxuss/lemma 1.7.1 → 1.8.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 +69 -8
- package/bin/brain-ingest.js +56 -0
- package/dist/cjs/mcp/tools.d.ts.map +1 -1
- package/dist/cjs/mcp/tools.js +247 -48
- package/dist/cjs/mcp/tools.js.map +1 -1
- package/dist/cjs/pr-review/bridge/BrainBridge.d.ts +11 -1
- package/dist/cjs/pr-review/bridge/BrainBridge.d.ts.map +1 -1
- package/dist/cjs/pr-review/bridge/BrainBridge.js +18 -23
- package/dist/cjs/pr-review/bridge/BrainBridge.js.map +1 -1
- package/dist/cjs/protocol/utils.d.ts.map +1 -1
- package/dist/cjs/protocol/utils.js +6 -5
- package/dist/cjs/protocol/utils.js.map +1 -1
- package/dist/cjs/proxy/ComplexityRouter.d.ts +7 -0
- package/dist/cjs/proxy/ComplexityRouter.d.ts.map +1 -1
- package/dist/cjs/proxy/ComplexityRouter.js +18 -5
- package/dist/cjs/proxy/ComplexityRouter.js.map +1 -1
- package/dist/cjs/subconscious/GitIngest.d.ts +48 -0
- package/dist/cjs/subconscious/GitIngest.d.ts.map +1 -0
- package/dist/cjs/subconscious/GitIngest.js +175 -0
- package/dist/cjs/subconscious/GitIngest.js.map +1 -0
- package/dist/cjs/subconscious/TheBrainV2.d.ts +23 -1
- package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
- package/dist/cjs/subconscious/TheBrainV2.js +124 -17
- package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
- package/dist/cjs/utils/ConversationCheckpoint.d.ts +2 -0
- package/dist/cjs/utils/ConversationCheckpoint.d.ts.map +1 -1
- package/dist/cjs/utils/ConversationCheckpoint.js +38 -0
- package/dist/cjs/utils/ConversationCheckpoint.js.map +1 -1
- package/dist/cjs/utils/TokenReceipt.d.ts +6 -0
- package/dist/cjs/utils/TokenReceipt.d.ts.map +1 -1
- package/dist/cjs/utils/TokenReceipt.js +9 -0
- package/dist/cjs/utils/TokenReceipt.js.map +1 -1
- package/dist/esm/mcp/tools.d.ts.map +1 -1
- package/dist/esm/mcp/tools.js +250 -51
- package/dist/esm/mcp/tools.js.map +1 -1
- package/dist/esm/pr-review/bridge/BrainBridge.d.ts +11 -1
- package/dist/esm/pr-review/bridge/BrainBridge.d.ts.map +1 -1
- package/dist/esm/pr-review/bridge/BrainBridge.js +18 -20
- package/dist/esm/pr-review/bridge/BrainBridge.js.map +1 -1
- package/dist/esm/protocol/utils.d.ts.map +1 -1
- package/dist/esm/protocol/utils.js +6 -5
- package/dist/esm/protocol/utils.js.map +1 -1
- package/dist/esm/proxy/ComplexityRouter.d.ts +7 -0
- package/dist/esm/proxy/ComplexityRouter.d.ts.map +1 -1
- package/dist/esm/proxy/ComplexityRouter.js +18 -5
- package/dist/esm/proxy/ComplexityRouter.js.map +1 -1
- package/dist/esm/subconscious/GitIngest.d.ts +48 -0
- package/dist/esm/subconscious/GitIngest.d.ts.map +1 -0
- package/dist/esm/subconscious/GitIngest.js +166 -0
- package/dist/esm/subconscious/GitIngest.js.map +1 -0
- package/dist/esm/subconscious/TheBrainV2.d.ts +23 -1
- package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
- package/dist/esm/subconscious/TheBrainV2.js +124 -17
- package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
- package/dist/esm/utils/ConversationCheckpoint.d.ts +2 -0
- package/dist/esm/utils/ConversationCheckpoint.d.ts.map +1 -1
- package/dist/esm/utils/ConversationCheckpoint.js +37 -0
- package/dist/esm/utils/ConversationCheckpoint.js.map +1 -1
- package/dist/esm/utils/TokenReceipt.d.ts +6 -0
- package/dist/esm/utils/TokenReceipt.d.ts.map +1 -1
- package/dist/esm/utils/TokenReceipt.js +8 -0
- package/dist/esm/utils/TokenReceipt.js.map +1 -1
- package/package.json +4 -2
package/dist/esm/mcp/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { execSync, spawnSync } from "child_process";
|
|
4
|
+
import { execSync, execFileSync, spawnSync } from "child_process";
|
|
5
5
|
import os from "os";
|
|
6
6
|
import axios from "axios";
|
|
7
7
|
import * as ts from "typescript";
|
|
@@ -18,7 +18,7 @@ import { readTokenBudgeted } from "../utils/TokenBudgetedReader.js";
|
|
|
18
18
|
import { bulkFileDigest } from "../utils/BulkFileDigest.js";
|
|
19
19
|
import { autoContextBundle } from "../utils/AutoContextBundle.js";
|
|
20
20
|
import { checkRepeat, recordOutput } from "../utils/RepeatGuard.js";
|
|
21
|
-
import { saveCheckpoint, loadLatestCheckpoint, buildCheckpointSummary } from "../utils/ConversationCheckpoint.js";
|
|
21
|
+
import { saveCheckpoint, loadLatestCheckpoint, loadRecentCheckpoints, buildCheckpointSummary } from "../utils/ConversationCheckpoint.js";
|
|
22
22
|
import { runTestAutofix } from "../utils/TestAutofixInterceptor.js";
|
|
23
23
|
import { findPattern, storePattern, getPatternStats } from "../utils/PromptPatternCache.js";
|
|
24
24
|
import { runAnalysis, parseDiff } from "../pr-review/PRReviewEngine.js";
|
|
@@ -33,7 +33,7 @@ import { surgicalASTInsert } from "../utils/SurgicalASTInsert.js";
|
|
|
33
33
|
import { getInfraToolDefinitions, getInfraToolHandlers, INFRA_TOOL_NAMES } from "../infra/mcp-tools.js";
|
|
34
34
|
import { resolveToolSurface, buildToolboxCatalog } from "./tool-profiles.js";
|
|
35
35
|
import { lookupStateHash, storeStateHash } from "../utils/StateHashCache.js";
|
|
36
|
-
import { recordReceiptEvent, getReceiptSummary } from "../utils/TokenReceipt.js";
|
|
36
|
+
import { recordReceiptEvent, getReceiptSummary, getLedgerLength } from "../utils/TokenReceipt.js";
|
|
37
37
|
import { distillCommandOutput, buildDistillFooter, readRegion } from "../utils/CommandOutputDistiller.js";
|
|
38
38
|
import { findMatch, reindentReplacement } from "../utils/PatchMatcher.js";
|
|
39
39
|
import { searchWorkspace, groupSearchResults, parseExtensionFilter } from "../utils/WorkspaceSearch.js";
|
|
@@ -58,7 +58,7 @@ function isProUser() {
|
|
|
58
58
|
}
|
|
59
59
|
const FREE_TOOLS = new Set([
|
|
60
60
|
// Cache & Memory — the hook that shows instant value
|
|
61
|
-
"smarter_cache", "state_hash_cache", "token_receipt", "search_memory", "store_memory",
|
|
61
|
+
"smarter_cache", "state_hash_cache", "token_receipt", "search_memory", "store_memory", "get_project_history",
|
|
62
62
|
// Token optimization — shows what they're saving
|
|
63
63
|
"token_budget", "squeeze_prompt", "turbosqueeze",
|
|
64
64
|
// Utility — just enough to function
|
|
@@ -103,13 +103,18 @@ const toolDefinitions = [
|
|
|
103
103
|
properties: {
|
|
104
104
|
query: { type: "string", description: "The natural language query" },
|
|
105
105
|
limit: { type: "number", description: "Maximum results to return", default: 5 },
|
|
106
|
+
minSimilarity: {
|
|
107
|
+
type: "number",
|
|
108
|
+
description: "Similarity floor (0.0-1.0) a memory must clear to be returned. Lowering this surfaces loosely related memories that are usually noise — leave it alone unless a known-relevant memory is being filtered out.",
|
|
109
|
+
default: 0.75,
|
|
110
|
+
},
|
|
106
111
|
},
|
|
107
112
|
required: ["query"],
|
|
108
113
|
},
|
|
109
114
|
},
|
|
110
115
|
{
|
|
111
116
|
name: "store_memory",
|
|
112
|
-
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.",
|
|
117
|
+
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. If the answer is really about one function/class rather than the whole file, pass `symbols` instead (or in addition) so an unrelated edit elsewhere in that file doesn't stale it out. Pass outcome='failed' for an approach that was tried and did NOT work — that's just as worth remembering as a fix, so the Brain can warn 'already tried, didn't work' instead of only ever suggesting reuse.",
|
|
113
118
|
inputSchema: {
|
|
114
119
|
type: "object",
|
|
115
120
|
properties: {
|
|
@@ -117,6 +122,19 @@ const toolDefinitions = [
|
|
|
117
122
|
response: { type: "string", description: "The complete technical content to memorize" },
|
|
118
123
|
provider: { type: "string", description: "Optional model provider name", default: "generic" },
|
|
119
124
|
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." },
|
|
125
|
+
symbols: {
|
|
126
|
+
type: "array",
|
|
127
|
+
items: {
|
|
128
|
+
type: "object",
|
|
129
|
+
properties: {
|
|
130
|
+
filePath: { type: "string", description: "Path (relative to project root) containing the symbol" },
|
|
131
|
+
symbolName: { type: "string", description: "Function/class/interface/type/enum/const name" },
|
|
132
|
+
},
|
|
133
|
+
required: ["filePath", "symbolName"],
|
|
134
|
+
},
|
|
135
|
+
description: "Symbols (not whole files) this answer depends on. Freshness is then judged by that symbol's own source, so edits elsewhere in the same file don't stale this memory out.",
|
|
136
|
+
},
|
|
137
|
+
outcome: { type: "string", enum: ["confirmed", "failed"], description: "Default 'confirmed' (a verified working solution). Set 'failed' to record a dead end — an approach ruled out, not one to recommend." },
|
|
120
138
|
},
|
|
121
139
|
required: ["query", "response"],
|
|
122
140
|
},
|
|
@@ -268,6 +286,16 @@ const toolDefinitions = [
|
|
|
268
286
|
properties: {},
|
|
269
287
|
},
|
|
270
288
|
},
|
|
289
|
+
{
|
|
290
|
+
name: "get_project_history",
|
|
291
|
+
description: "Answers 'what have we done in this project': merges recent git commits, session checkpoints, and The Brain's memories (decisions, fixes, prior PR reviews) for the current project into one narrative. Use this instead of piecing the same picture together from search_memory + git log + reading checkpoint files separately.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: {
|
|
295
|
+
limit: { type: "number", description: "Max items per section (commits, checkpoints, Brain memories)", default: 10 },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
271
299
|
{
|
|
272
300
|
name: "get_ast_hologram",
|
|
273
301
|
description: "Generate a dense, token-efficient Holographic AST Map of the workspace using the TypeScript compiler. Returns structured JSON of all exported symbols with file paths and line numbers.",
|
|
@@ -1116,6 +1144,7 @@ const toolHandlers = {
|
|
|
1116
1144
|
auto_context_bundle: handleAutoContextBundle,
|
|
1117
1145
|
repeat_guard: handleRepeatGuard,
|
|
1118
1146
|
conversation_checkpoint: handleConversationCheckpoint,
|
|
1147
|
+
get_project_history: handleGetProjectHistory,
|
|
1119
1148
|
test_autofix_interceptor: handleTestAutofixInterceptor,
|
|
1120
1149
|
prompt_pattern_cache: handlePromptPatternCache,
|
|
1121
1150
|
lemma_toolbox: handleToolbox,
|
|
@@ -1156,6 +1185,41 @@ async function handleToolbox(args) {
|
|
|
1156
1185
|
}
|
|
1157
1186
|
throw new Error(`Unknown action: ${action}. Use 'list', 'schema', or 'call'.`);
|
|
1158
1187
|
}
|
|
1188
|
+
/** Tools whose whole job is to put file contents into the model's context. */
|
|
1189
|
+
const FILE_READ_TOOLS = new Set([
|
|
1190
|
+
"read_workspace_file",
|
|
1191
|
+
"smart_file_slice",
|
|
1192
|
+
"get_symbol_surgical_context",
|
|
1193
|
+
"get_ast_hologram",
|
|
1194
|
+
"list_workspace_dir",
|
|
1195
|
+
"search_workspace",
|
|
1196
|
+
"read_token_budgeted",
|
|
1197
|
+
"bulk_file_digest",
|
|
1198
|
+
"import_tree_context",
|
|
1199
|
+
]);
|
|
1200
|
+
function receiptTypeForTool(name) {
|
|
1201
|
+
return FILE_READ_TOOLS.has(name) ? "file_read" : "tool_call";
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* A receipt entry is only useful if it says what the call was about. Pull the most
|
|
1205
|
+
* identifying argument without dragging whole file contents into the ledger.
|
|
1206
|
+
*/
|
|
1207
|
+
function receiptLabelMeta(name, args) {
|
|
1208
|
+
if (!args)
|
|
1209
|
+
return {};
|
|
1210
|
+
const meta = {};
|
|
1211
|
+
if (typeof args.filePath === "string")
|
|
1212
|
+
meta.filePath = args.filePath;
|
|
1213
|
+
if (typeof args.dirPath === "string")
|
|
1214
|
+
meta.dirPath = args.dirPath;
|
|
1215
|
+
if (typeof args.query === "string")
|
|
1216
|
+
meta.query = args.query.substring(0, 100);
|
|
1217
|
+
if (typeof args.command === "string")
|
|
1218
|
+
meta.command = args.command.substring(0, 100);
|
|
1219
|
+
if (typeof args.symbolName === "string")
|
|
1220
|
+
meta.symbolName = args.symbolName;
|
|
1221
|
+
return meta;
|
|
1222
|
+
}
|
|
1159
1223
|
export function setupToolsHandlers(server, onToolCall) {
|
|
1160
1224
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1161
1225
|
tools: toolDefinitionsArray,
|
|
@@ -1190,7 +1254,16 @@ export function setupToolsHandlers(server, onToolCall) {
|
|
|
1190
1254
|
throw new Error(`Unknown tool: ${name}`);
|
|
1191
1255
|
}
|
|
1192
1256
|
try {
|
|
1257
|
+
// Sampled around the handler so a tool that logs its own, more specific event
|
|
1258
|
+
// (a cache hit, a miss) isn't double-counted by the generic entry below.
|
|
1259
|
+
const ledgerBefore = getLedgerLength();
|
|
1193
1260
|
const result = await handler((args || {}));
|
|
1261
|
+
if (getLedgerLength() === ledgerBefore) {
|
|
1262
|
+
recordReceiptEvent(receiptTypeForTool(name), name, {
|
|
1263
|
+
tool: name,
|
|
1264
|
+
...receiptLabelMeta(name, args),
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1194
1267
|
const tokensImpact = estimateTokensFromResult(result);
|
|
1195
1268
|
onToolCall?.({
|
|
1196
1269
|
tool: name,
|
|
@@ -1225,26 +1298,56 @@ async function handleScrubPrivacy(args) {
|
|
|
1225
1298
|
const { maskedPrompt } = scrubber.mask(text);
|
|
1226
1299
|
return { content: [{ type: "text", text: maskedPrompt }] };
|
|
1227
1300
|
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Minimum similarity for a stored memory to be offered for reuse. Shared by
|
|
1303
|
+
* search_memory and smarter_cache so the two never disagree about whether the same
|
|
1304
|
+
* query is a hit.
|
|
1305
|
+
*/
|
|
1306
|
+
const DEFAULT_MEMORY_SIMILARITY_FLOOR = 0.75;
|
|
1228
1307
|
async function handleSearchMemory(args) {
|
|
1229
1308
|
const query = args?.query;
|
|
1230
1309
|
const limit = args?.limit || 5;
|
|
1310
|
+
// A floor of 0 returns the nearest neighbour no matter how unrelated it is, and the
|
|
1311
|
+
// formatting below then presents it as a reusable memory. Match smarter_cache's
|
|
1312
|
+
// threshold so both paths agree on what counts as a hit.
|
|
1313
|
+
const minSimilarity = typeof args?.minSimilarity === "number" ? args.minSimilarity : DEFAULT_MEMORY_SIMILARITY_FLOOR;
|
|
1231
1314
|
if (!query)
|
|
1232
1315
|
throw new Error("Query is required");
|
|
1233
1316
|
try {
|
|
1234
1317
|
const brain = getBrain();
|
|
1235
|
-
const results = brain.search(query, limit,
|
|
1318
|
+
const results = brain.search(query, limit, minSimilarity, { projectId: deriveProjectId() });
|
|
1236
1319
|
if (results.length === 0) {
|
|
1237
|
-
|
|
1320
|
+
recordReceiptEvent("reasoning", query.substring(0, 100), {
|
|
1321
|
+
tool: "search_memory",
|
|
1322
|
+
reason: `no memory above the ${minSimilarity} similarity floor`,
|
|
1323
|
+
});
|
|
1324
|
+
return {
|
|
1325
|
+
content: [{
|
|
1326
|
+
type: "text",
|
|
1327
|
+
text: `No memory in Lemma's Brain scored at or above the ${(minSimilarity * 100).toFixed(0)}% similarity floor for this query. Investigate from scratch — there is nothing safe to reuse.`,
|
|
1328
|
+
}],
|
|
1329
|
+
};
|
|
1238
1330
|
}
|
|
1239
1331
|
const fresh = results.filter((r) => r.fresh);
|
|
1240
1332
|
const stale = results.filter((r) => !r.fresh);
|
|
1241
|
-
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)}`;
|
|
1333
|
+
const formatResult = (r, i) => `Result ${i + 1} (Similarity: ${(r.similarity * 100).toFixed(1)}%)${r.outcome === "failed" ? "\n⚠️ TAGGED AS A FAILED ATTEMPT — this was tried before and did NOT work. Do not repeat it; treat this as a warning, not a suggestion." : ""}\nPrompt: ${r.query.substring(0, 300)}...\nResponse: ${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
|
|
1242
1334
|
if (fresh.length > 0) {
|
|
1243
1335
|
// Only a fresh hit (or an untracked, purely conceptual entry) counts as a real
|
|
1244
1336
|
// avoided re-investigation — this is the only case worth crediting as savings.
|
|
1245
1337
|
const bestFresh = fresh[0];
|
|
1246
1338
|
const tokensSaved = Math.max(100, Math.floor(String(bestFresh.response).length / 4));
|
|
1247
1339
|
reportSavings({ source: "cache", tokens: tokensSaved, toolName: "search_memory", query: query.substring(0, 100) });
|
|
1340
|
+
recordReceiptEvent("semantic_cache_hit", query.substring(0, 100), {
|
|
1341
|
+
tool: "search_memory",
|
|
1342
|
+
similarity: bestFresh.similarity,
|
|
1343
|
+
tokensSaved,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
recordReceiptEvent("reasoning", query.substring(0, 100), {
|
|
1348
|
+
tool: "search_memory",
|
|
1349
|
+
reason: `${stale.length} similar memory/memories found but all stale — must re-verify`,
|
|
1350
|
+
});
|
|
1248
1351
|
}
|
|
1249
1352
|
const parts = [];
|
|
1250
1353
|
if (fresh.length > 0) {
|
|
@@ -1268,20 +1371,27 @@ async function handleStoreMemory(args) {
|
|
|
1268
1371
|
const responseText = args?.response;
|
|
1269
1372
|
const provider = args?.provider || "generic";
|
|
1270
1373
|
const filePaths = Array.isArray(args?.filePaths) ? args.filePaths : undefined;
|
|
1374
|
+
const outcome = args?.outcome === "failed" ? "failed" : undefined;
|
|
1375
|
+
const symbols = Array.isArray(args?.symbols)
|
|
1376
|
+
? args.symbols.filter((s) => s?.filePath && s?.symbolName)
|
|
1377
|
+
: undefined;
|
|
1271
1378
|
if (!query || !responseText)
|
|
1272
1379
|
throw new Error("Query and response are required");
|
|
1273
1380
|
try {
|
|
1274
1381
|
const brain = getBrain();
|
|
1275
|
-
const storeRes = brain.store(query, responseText, provider, 0.92, filePaths);
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1382
|
+
const storeRes = brain.store(query, responseText, provider, 0.92, filePaths, undefined, outcome, symbols);
|
|
1383
|
+
// Storing a memory saves nothing — it only creates the chance of a saving later.
|
|
1384
|
+
// Crediting tokens here inflated the ledger on write and then credited the same
|
|
1385
|
+
// answer again on every read. The saving is booked by search_memory on a fresh hit.
|
|
1386
|
+
recordReceiptEvent("tool_call", `store_memory: ${query.substring(0, 100)}`, {
|
|
1387
|
+
tool: "store_memory",
|
|
1388
|
+
filePaths,
|
|
1389
|
+
note: "memory written — no tokens saved yet",
|
|
1282
1390
|
});
|
|
1283
1391
|
const trackingNote = filePaths && filePaths.length > 0 ? ` Tracking freshness against ${filePaths.length} file(s) — this memory auto-invalidates if they change.` : "";
|
|
1284
|
-
|
|
1392
|
+
const symbolNote = symbols && symbols.length > 0 ? ` Tracking freshness against ${symbols.length} symbol(s) specifically — unrelated edits elsewhere in those files won't stale this out.` : "";
|
|
1393
|
+
const outcomeNote = outcome === "failed" ? " Tagged as a FAILED attempt — future searches will surface it as a warning, not a suggestion." : "";
|
|
1394
|
+
return { content: [{ type: "text", text: `Success: Memory stored. ${storeRes.reason}${trackingNote}${symbolNote}${outcomeNote}` }] };
|
|
1285
1395
|
}
|
|
1286
1396
|
catch (e) {
|
|
1287
1397
|
logError("store_memory", e);
|
|
@@ -1800,7 +1910,60 @@ async function handleGetProjectOnboarding(_args) {
|
|
|
1800
1910
|
],
|
|
1801
1911
|
};
|
|
1802
1912
|
}
|
|
1803
|
-
|
|
1913
|
+
// ── Project History (aggregate: git log + checkpoints + Brain) ─────
|
|
1914
|
+
/**
|
|
1915
|
+
* "What have we done in this project" today means piecing together search_memory, git log,
|
|
1916
|
+
* and manually reading .lemma/session/ checkpoint files by hand — three separate calls whose
|
|
1917
|
+
* results the model has to merge itself. This does that merge in one call.
|
|
1918
|
+
*/
|
|
1919
|
+
async function handleGetProjectHistory(args) {
|
|
1920
|
+
const cwd = process.cwd();
|
|
1921
|
+
const limit = typeof args?.limit === "number" ? args.limit : 10;
|
|
1922
|
+
try {
|
|
1923
|
+
const projectId = deriveProjectId(cwd);
|
|
1924
|
+
const brainEntries = getBrain()
|
|
1925
|
+
.getEntriesForProject(projectId)
|
|
1926
|
+
.sort((a, b) => (b.hits - a.hits) || (new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()))
|
|
1927
|
+
.slice(0, limit);
|
|
1928
|
+
const checkpoints = loadRecentCheckpoints(cwd, Math.min(limit, 5));
|
|
1929
|
+
let recentCommits = [];
|
|
1930
|
+
try {
|
|
1931
|
+
const log = execFileSync("git", ["-C", cwd, "log", `-${limit}`, "--pretty=format:%h %s (%ar)"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1932
|
+
recentCommits = log.split("\n").filter(Boolean);
|
|
1933
|
+
}
|
|
1934
|
+
catch {
|
|
1935
|
+
// Not a git repo, or no commits yet — sections below just won't include this one.
|
|
1936
|
+
}
|
|
1937
|
+
const parts = [`# Project history — ${path.basename(cwd)}`];
|
|
1938
|
+
if (recentCommits.length > 0) {
|
|
1939
|
+
parts.push(`## Recent commits\n${recentCommits.map((c) => `- ${c}`).join("\n")}`);
|
|
1940
|
+
}
|
|
1941
|
+
if (checkpoints.length > 0) {
|
|
1942
|
+
const checkpointLines = checkpoints.map((cp) => {
|
|
1943
|
+
const date = new Date(cp.timestamp).toISOString().replace("T", " ").slice(0, 16);
|
|
1944
|
+
return `- **${date}** — ${cp.summary.replace(/\n+/g, " ")}`;
|
|
1945
|
+
});
|
|
1946
|
+
parts.push(`## Session checkpoints\n${checkpointLines.join("\n")}`);
|
|
1947
|
+
}
|
|
1948
|
+
if (brainEntries.length > 0) {
|
|
1949
|
+
const brainLines = brainEntries.map((e) => {
|
|
1950
|
+
const tag = e.outcome === "failed" ? " ⚠️ FAILED ATTEMPT" : "";
|
|
1951
|
+
const snippet = e.response.length > 300 ? `${e.response.substring(0, 300)}...` : e.response;
|
|
1952
|
+
return `- **${e.query}**${tag} _(${e.provider}, ${e.hits} hit(s))_\n ${snippet}`;
|
|
1953
|
+
});
|
|
1954
|
+
parts.push(`## Brain memories (decisions, fixes, prior reviews)\n${brainLines.join("\n")}`);
|
|
1955
|
+
}
|
|
1956
|
+
if (parts.length === 1) {
|
|
1957
|
+
parts.push("Nothing recorded yet — no git history, checkpoints, or Brain memories found for this project.");
|
|
1958
|
+
}
|
|
1959
|
+
return { content: [{ type: "text", text: parts.join("\n\n") }] };
|
|
1960
|
+
}
|
|
1961
|
+
catch (e) {
|
|
1962
|
+
logError("get_project_history", e);
|
|
1963
|
+
return { content: [{ type: "text", text: `get_project_history failed: ${e.message}` }] };
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
function extractSymbolsWithTsCompiler(filePath, relPath, parseErrors) {
|
|
1804
1967
|
try {
|
|
1805
1968
|
const src = fs.readFileSync(filePath, "utf8");
|
|
1806
1969
|
const sourceFile = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
|
|
@@ -1808,7 +1971,10 @@ function extractSymbolsWithTsCompiler(filePath, relPath) {
|
|
|
1808
1971
|
function visit(node) {
|
|
1809
1972
|
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
1810
1973
|
const isExport = mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
1811
|
-
|
|
1974
|
+
// node.parent is undefined on the SourceFile root itself, and ts.isSourceFile()
|
|
1975
|
+
// dereferences .kind without a guard — calling it unguarded threw on the very
|
|
1976
|
+
// first visit(), so the catch below swallowed it and every file returned [].
|
|
1977
|
+
if (isExport || (node.parent && ts.isSourceFile(node.parent))) {
|
|
1812
1978
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
1813
1979
|
symbols.push({
|
|
1814
1980
|
kind: "function",
|
|
@@ -1877,10 +2043,11 @@ function extractSymbolsWithTsCompiler(filePath, relPath) {
|
|
|
1877
2043
|
}
|
|
1878
2044
|
catch (err) {
|
|
1879
2045
|
logWarn("get_ast_hologram", `Could not parse ${relPath}: ${err}`);
|
|
2046
|
+
parseErrors?.push(`${relPath}: ${err}`);
|
|
1880
2047
|
return [];
|
|
1881
2048
|
}
|
|
1882
2049
|
}
|
|
1883
|
-
function walkDirForHologram(dir, rel, extensions) {
|
|
2050
|
+
function walkDirForHologram(dir, rel, extensions, stats) {
|
|
1884
2051
|
let allSymbols = [];
|
|
1885
2052
|
try {
|
|
1886
2053
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -1890,18 +2057,20 @@ function walkDirForHologram(dir, rel, extensions) {
|
|
|
1890
2057
|
const fullPath = path.join(dir, entry.name);
|
|
1891
2058
|
const relPath = rel ? path.join(rel, entry.name) : entry.name;
|
|
1892
2059
|
if (entry.isDirectory()) {
|
|
1893
|
-
allSymbols = allSymbols.concat(walkDirForHologram(fullPath, relPath, extensions));
|
|
2060
|
+
allSymbols = allSymbols.concat(walkDirForHologram(fullPath, relPath, extensions, stats));
|
|
1894
2061
|
}
|
|
1895
2062
|
else {
|
|
1896
2063
|
const ext = entry.name.split(".").pop() || "";
|
|
1897
2064
|
if (extensions.includes(ext)) {
|
|
1898
|
-
|
|
2065
|
+
stats.filesScanned++;
|
|
2066
|
+
allSymbols = allSymbols.concat(extractSymbolsWithTsCompiler(fullPath, relPath, stats.parseErrors));
|
|
1899
2067
|
}
|
|
1900
2068
|
}
|
|
1901
2069
|
}
|
|
1902
2070
|
}
|
|
1903
2071
|
catch (err) {
|
|
1904
2072
|
logWarn("get_ast_hologram", `Error walking dir ${dir}: ${err}`);
|
|
2073
|
+
stats.parseErrors.push(`walk ${rel || "."}: ${err}`);
|
|
1905
2074
|
}
|
|
1906
2075
|
return allSymbols;
|
|
1907
2076
|
}
|
|
@@ -1910,18 +2079,32 @@ async function handleGetAstHologram(args) {
|
|
|
1910
2079
|
const extensions = args?.extensions || ["ts", "tsx", "js", "jsx"];
|
|
1911
2080
|
const workspaceRoot = process.cwd();
|
|
1912
2081
|
const { resolved } = safeResolvePath(workspaceRoot, dirPath);
|
|
1913
|
-
const
|
|
2082
|
+
const stats = { filesScanned: 0, parseErrors: [] };
|
|
2083
|
+
const symbols = walkDirForHologram(resolved, dirPath, extensions, stats);
|
|
1914
2084
|
const byFile = {};
|
|
1915
2085
|
symbols.forEach((s) => {
|
|
1916
2086
|
if (!byFile[s.file])
|
|
1917
2087
|
byFile[s.file] = [];
|
|
1918
2088
|
byFile[s.file].push({ kind: s.kind, name: s.name, line: s.line });
|
|
1919
2089
|
});
|
|
2090
|
+
// An empty map is reported as a failure, not as an answer: a silent "0 symbols" is
|
|
2091
|
+
// indistinguishable from a working scan of an empty dir, which is how a parse bug
|
|
2092
|
+
// stayed invisible here before. Say when nothing could be read.
|
|
2093
|
+
const warning = stats.filesScanned === 0
|
|
2094
|
+
? `No files matching [${extensions.join(", ")}] were found under "${dirPath || "."}" — check the path and extensions.`
|
|
2095
|
+
: symbols.length === 0
|
|
2096
|
+
? `Scanned ${stats.filesScanned} file(s) but extracted 0 symbols — this is very likely an extraction failure, not an empty codebase. Do not treat this as "the directory has no exports".`
|
|
2097
|
+
: undefined;
|
|
1920
2098
|
const hologram = {
|
|
1921
2099
|
workspace: path.basename(workspaceRoot),
|
|
1922
2100
|
scannedDir: dirPath || ".",
|
|
1923
2101
|
totalSymbols: symbols.length,
|
|
2102
|
+
filesScanned: stats.filesScanned,
|
|
1924
2103
|
totalFiles: Object.keys(byFile).length,
|
|
2104
|
+
...(warning ? { warning } : {}),
|
|
2105
|
+
...(stats.parseErrors.length > 0
|
|
2106
|
+
? { parseErrors: stats.parseErrors.slice(0, 10), parseErrorCount: stats.parseErrors.length }
|
|
2107
|
+
: {}),
|
|
1925
2108
|
map: byFile,
|
|
1926
2109
|
};
|
|
1927
2110
|
return { content: [{ type: "text", text: JSON.stringify(hologram, null, 2) }] };
|
|
@@ -2100,7 +2283,7 @@ async function handleGetTelepathicHints(args) {
|
|
|
2100
2283
|
const similarity = (r.similarity * 100).toFixed(1);
|
|
2101
2284
|
const prompt = r.query || "Unknown";
|
|
2102
2285
|
const responseContent = r.response || "";
|
|
2103
|
-
hintsText += `### Hint ${i + 1} (${similarity}% match)\n`;
|
|
2286
|
+
hintsText += `### Hint ${i + 1} (${similarity}% match)${r.outcome === "failed" ? " — ⚠️ FAILED ATTEMPT, do not repeat" : ""}\n`;
|
|
2104
2287
|
hintsText += `**Memory:** ${prompt}\n\n`;
|
|
2105
2288
|
hintsText += `${responseContent.substring(0, 400)}${responseContent.length > 400 ? "..." : ""}\n\n---\n\n`;
|
|
2106
2289
|
});
|
|
@@ -2324,7 +2507,7 @@ async function handleCompressContext(args) {
|
|
|
2324
2507
|
async function handleSmarterCache(args) {
|
|
2325
2508
|
const query = args?.query;
|
|
2326
2509
|
const context = args?.context || "";
|
|
2327
|
-
const threshold = typeof args?.threshold === "number" ? args.threshold :
|
|
2510
|
+
const threshold = typeof args?.threshold === "number" ? args.threshold : DEFAULT_MEMORY_SIMILARITY_FLOOR;
|
|
2328
2511
|
if (!query)
|
|
2329
2512
|
throw new Error("query is required");
|
|
2330
2513
|
const fullQuery = context ? `${query}\n\nContext: ${context}` : query;
|
|
@@ -2449,48 +2632,64 @@ async function handleTokenReceipt(args) {
|
|
|
2449
2632
|
}
|
|
2450
2633
|
// ── Token Budget ─────────────────────────────────────────────────
|
|
2451
2634
|
async function handleTokenBudget(_args) {
|
|
2452
|
-
const statsFile = path.join(os.homedir(), '.lemma-cache/stats.json');
|
|
2453
2635
|
const port = getProxyPort();
|
|
2454
|
-
|
|
2636
|
+
const schemaTokens = Math.round(getToolSchemaChars() / 4);
|
|
2637
|
+
// /api/savings-breakdown serves SavingsLedger.getSnapshot() — the same ledger that
|
|
2638
|
+
// already nets tokensSaved against what Lemma itself spent (MCP schema injection,
|
|
2639
|
+
// tool-result bytes, cache misses). There is no separate "membership multiplier" to
|
|
2640
|
+
// compute: reusing that one number keeps this tool from reporting a bigger win than
|
|
2641
|
+
// the ledger the user can audit via `token_receipt` actually shows.
|
|
2642
|
+
let snapshot = null;
|
|
2455
2643
|
try {
|
|
2456
|
-
|
|
2644
|
+
const resp = await axios.get(`http://localhost:${port}/api/savings-breakdown`);
|
|
2645
|
+
snapshot = resp.data;
|
|
2457
2646
|
}
|
|
2458
2647
|
catch { }
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2648
|
+
if (!snapshot) {
|
|
2649
|
+
return {
|
|
2650
|
+
content: [{
|
|
2651
|
+
type: "text",
|
|
2652
|
+
text: JSON.stringify({
|
|
2653
|
+
note: "El proxy de Lemma no está corriendo, así que no hay bitácora de ahorro/costo que leer — no se inventa un número aquí. Arranca el proxy para que este reporte sea real.",
|
|
2654
|
+
perTurnOverhead: { ...getToolSurfaceSummary(), schemaTokens },
|
|
2655
|
+
}, null, 2),
|
|
2656
|
+
}],
|
|
2657
|
+
};
|
|
2463
2658
|
}
|
|
2464
|
-
|
|
2465
|
-
const
|
|
2466
|
-
const monthlyTokens = stats.monthlyTokens || stats.totalTokens || 100000;
|
|
2467
|
-
const multiplier = monthlyTokens > 0 ? ((monthlyTokens + totalTokensSaved) / monthlyTokens).toFixed(1) : "N/A";
|
|
2468
|
-
const toolCallCount = ledger.length || stats.toolCalls || 0;
|
|
2659
|
+
const { total, totalCost, net } = snapshot;
|
|
2660
|
+
const ratioStr = net.ratio === Infinity ? "n/a (sin costo registrado todavía)" : `${net.ratio.toFixed(2)}x`;
|
|
2469
2661
|
return {
|
|
2470
2662
|
content: [{
|
|
2471
2663
|
type: "text",
|
|
2472
2664
|
text: JSON.stringify({
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2665
|
+
netRatio: ratioStr,
|
|
2666
|
+
note: "netRatio = tokensSaved / tokensSpent, ambos de la misma bitácora auditable que expone token_receipt. Por debajo de 1.0x, Lemma está costando más de lo que ahorra en esta sesión.",
|
|
2667
|
+
tokensSaved: total.tokensSaved.toLocaleString(),
|
|
2668
|
+
tokensSpent: totalCost.tokensSpent.toLocaleString(),
|
|
2669
|
+
netTokens: net.netTokens.toLocaleString(),
|
|
2670
|
+
netCostUSD: net.netCost.toFixed(4),
|
|
2671
|
+
breakdown: {
|
|
2672
|
+
saved: {
|
|
2673
|
+
cache: snapshot.cache?.tokensSaved ?? 0,
|
|
2674
|
+
contextSqueeze: snapshot.contextSqueeze?.tokensSaved ?? 0,
|
|
2675
|
+
historyPrune: snapshot.historyPrune?.tokensSaved ?? 0,
|
|
2676
|
+
complexityRouting: snapshot.complexityRouting?.tokensSaved ?? 0,
|
|
2677
|
+
clipboard: snapshot.clipboard?.tokensSaved ?? 0,
|
|
2678
|
+
},
|
|
2679
|
+
spent: {
|
|
2680
|
+
mcpInstructions: snapshot.costs?.mcpInstructions?.tokensSpent ?? 0,
|
|
2681
|
+
toolResult: snapshot.costs?.toolResult?.tokensSpent ?? 0,
|
|
2682
|
+
cacheMiss: snapshot.costs?.cacheMiss?.tokensSpent ?? 0,
|
|
2683
|
+
},
|
|
2485
2684
|
},
|
|
2486
2685
|
// The fixed cost of being connected, re-sent on every request of the session.
|
|
2487
2686
|
perTurnOverhead: {
|
|
2488
2687
|
...getToolSurfaceSummary(),
|
|
2489
|
-
schemaTokens
|
|
2688
|
+
schemaTokens,
|
|
2490
2689
|
note: "Hidden tools stay callable via lemma_toolbox. Set mcp.toolProfile='full' in lemma.config.json to advertise all of them.",
|
|
2491
2690
|
},
|
|
2492
|
-
recommendations:
|
|
2493
|
-
? ["
|
|
2691
|
+
recommendations: net.ratio !== Infinity && net.ratio < 1
|
|
2692
|
+
? ["netRatio < 1.0x: revisa si search_memory/smarter_cache se están llamando antes de razonar, o si el toolProfile anuncia más tools de las que este proyecto necesita — cada schema extra es costo fijo por turno."]
|
|
2494
2693
|
: ["Sigue usando turbosqueeze antes de cada code block grande.", "Usa smarter_cache antes de razonar."],
|
|
2495
2694
|
}, null, 2),
|
|
2496
2695
|
}],
|