devsmind-mcp 2.4.0 → 4.0.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 +317 -234
- package/dist/cli/activity.d.ts +9 -0
- package/dist/cli/activity.js +69 -0
- package/dist/cli/activity.js.map +1 -0
- package/dist/cli/describe.d.ts +31 -0
- package/dist/cli/describe.js +201 -0
- package/dist/cli/describe.js.map +1 -0
- package/dist/cli/diff.d.ts +14 -0
- package/dist/cli/diff.js +161 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/embed.d.ts +6 -0
- package/dist/cli/embed.js +109 -0
- package/dist/cli/embed.js.map +1 -0
- package/dist/cli/extract-agent.d.ts +66 -0
- package/dist/cli/extract-agent.js +192 -0
- package/dist/cli/extract-agent.js.map +1 -0
- package/dist/cli/feedback.d.ts +11 -0
- package/dist/cli/feedback.js +90 -0
- package/dist/cli/feedback.js.map +1 -0
- package/dist/cli/index.js +139 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +30 -0
- package/dist/cli/init.js +89 -18
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/integrations/memory-topics.d.ts +55 -0
- package/dist/cli/integrations/memory-topics.js +318 -0
- package/dist/cli/integrations/memory-topics.js.map +1 -0
- package/dist/cli/integrations/memory.d.ts +17 -6
- package/dist/cli/integrations/memory.js +52 -139
- package/dist/cli/integrations/memory.js.map +1 -1
- package/dist/cli/integrations/prompt.d.ts +8 -0
- package/dist/cli/integrations/prompt.js +24 -3
- package/dist/cli/integrations/prompt.js.map +1 -1
- package/dist/cli/integrations/registry.d.ts +26 -9
- package/dist/cli/integrations/registry.js +61 -30
- package/dist/cli/integrations/registry.js.map +1 -1
- package/dist/cli/llm-client.d.ts +116 -0
- package/dist/cli/llm-client.js +322 -0
- package/dist/cli/llm-client.js.map +1 -0
- package/dist/cli/rule.d.ts +22 -2
- package/dist/cli/rule.js +110 -34
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/runner.d.ts +21 -0
- package/dist/cli/runner.js +94 -276
- package/dist/cli/runner.js.map +1 -1
- package/dist/cli/sync-progress.d.ts +9 -0
- package/dist/cli/sync-progress.js +41 -0
- package/dist/cli/sync-progress.js.map +1 -0
- package/dist/cli/sync.js +5 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/view.js +3 -2
- package/dist/cli/view.js.map +1 -1
- package/dist/cli/workflow.js +26 -20
- package/dist/cli/workflow.js.map +1 -1
- package/dist/db/activity-graph.d.ts +55 -0
- package/dist/db/activity-graph.js +314 -0
- package/dist/db/activity-graph.js.map +1 -0
- package/dist/db/activity.d.ts +221 -0
- package/dist/db/activity.js +348 -0
- package/dist/db/activity.js.map +1 -0
- package/dist/db/analyze.js +18 -6
- package/dist/db/analyze.js.map +1 -1
- package/dist/db/database.d.ts +654 -102
- package/dist/db/database.js +1911 -558
- package/dist/db/database.js.map +1 -1
- package/dist/db/edges.d.ts +24 -0
- package/dist/db/edges.js +84 -0
- package/dist/db/edges.js.map +1 -1
- package/dist/db/embedder.d.ts +39 -0
- package/dist/db/embedder.js +0 -0
- package/dist/db/embedder.js.map +1 -0
- package/dist/db/feedback.d.ts +128 -0
- package/dist/db/feedback.js +182 -0
- package/dist/db/feedback.js.map +1 -0
- package/dist/db/file-diff.d.ts +32 -0
- package/dist/db/file-diff.js +110 -0
- package/dist/db/file-diff.js.map +1 -0
- package/dist/db/grep.d.ts +78 -0
- package/dist/db/grep.js +475 -0
- package/dist/db/grep.js.map +1 -0
- package/dist/db/index-build.d.ts +75 -0
- package/dist/db/index-build.js +177 -0
- package/dist/db/index-build.js.map +1 -0
- package/dist/db/message-revert.d.ts +63 -0
- package/dist/db/message-revert.js +258 -0
- package/dist/db/message-revert.js.map +1 -0
- package/dist/db/revert.d.ts +31 -0
- package/dist/db/revert.js +108 -0
- package/dist/db/revert.js.map +1 -0
- package/dist/db/schema.d.ts +49 -4
- package/dist/db/schema.js +125 -73
- package/dist/db/schema.js.map +1 -1
- package/dist/db/search-index.d.ts +65 -0
- package/dist/db/search-index.js +74 -0
- package/dist/db/search-index.js.map +1 -0
- package/dist/db/staging.d.ts +91 -5
- package/dist/db/staging.js +148 -22
- package/dist/db/staging.js.map +1 -1
- package/dist/mcp/server.d.ts +29 -7
- package/dist/mcp/server.js +2637 -878
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/vendor/3d-force-graph.min.js +5 -0
- package/dist/mcp/vendor/force-graph.min.js +5 -0
- package/dist/mcp/vendor/model/model_int8.onnx +0 -0
- package/dist/mcp/vendor/model/vocab.txt +30522 -0
- package/dist/mcp/vendor/three.min.js +7 -0
- package/dist/mcp/view.css +419 -0
- package/dist/mcp/view.html +161 -0
- package/dist/mcp/view.js +245 -0
- package/dist/mcp/view_chat.js +382 -0
- package/dist/mcp/view_graph.js +576 -0
- package/dist/mcp/visualizer.d.ts +25 -2
- package/dist/mcp/visualizer.js +31 -4
- package/dist/mcp/visualizer.js.map +1 -1
- package/dist/utils/ast.d.ts +89 -0
- package/dist/utils/ast.js +378 -10
- package/dist/utils/ast.js.map +1 -1
- package/dist/utils/diff.d.ts +44 -0
- package/dist/utils/diff.js +78 -0
- package/dist/utils/diff.js.map +1 -0
- package/dist/utils/edit.d.ts +6 -0
- package/dist/utils/edit.js +47 -14
- package/dist/utils/edit.js.map +1 -1
- package/dist/utils/scanner.d.ts +6 -4
- package/dist/utils/scanner.js +18 -8
- package/dist/utils/scanner.js.map +1 -1
- package/dist/utils/tokenize.d.ts +45 -0
- package/dist/utils/tokenize.js +129 -0
- package/dist/utils/tokenize.js.map +1 -0
- package/dist/utils/version.d.ts +14 -0
- package/dist/utils/version.js +61 -0
- package/dist/utils/version.js.map +1 -0
- package/package.json +18 -5
- package/dist/mcp/visualizer_2d.html +0 -635
- package/dist/mcp/visualizer_3d.html +0 -613
package/dist/mcp/server.js
CHANGED
|
@@ -37,6 +37,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.DEVSMIND_INSTRUCTIONS = exports.DEVSMIND_PORT = void 0;
|
|
40
|
+
exports.bindDevmindPath = bindDevmindPath;
|
|
41
|
+
exports.getBoundDevmindPath = getBoundDevmindPath;
|
|
42
|
+
exports.cleanup = cleanup;
|
|
43
|
+
exports.createMcpServer = createMcpServer;
|
|
44
|
+
exports.createHttpApp = createHttpApp;
|
|
40
45
|
exports.runHttpMcpServer = runHttpMcpServer;
|
|
41
46
|
exports.runStdioMcpServer = runStdioMcpServer;
|
|
42
47
|
exports.runMcpServer = runMcpServer;
|
|
@@ -44,19 +49,33 @@ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
|
44
49
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
45
50
|
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
46
51
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
52
|
+
const crypto = __importStar(require("crypto"));
|
|
47
53
|
const path = __importStar(require("path"));
|
|
48
54
|
const http = __importStar(require("http"));
|
|
49
55
|
const fs = __importStar(require("fs"));
|
|
50
56
|
const express_1 = __importDefault(require("express"));
|
|
51
57
|
const database_1 = require("../db/database");
|
|
52
58
|
const visualizer_1 = require("./visualizer");
|
|
59
|
+
const diff_1 = require("../utils/diff");
|
|
60
|
+
const revert_1 = require("../db/revert");
|
|
61
|
+
const activity_1 = require("../db/activity");
|
|
62
|
+
const activity_graph_1 = require("../db/activity-graph");
|
|
63
|
+
const message_revert_1 = require("../db/message-revert");
|
|
64
|
+
const file_diff_1 = require("../db/file-diff");
|
|
53
65
|
const indexer_1 = require("../db/indexer");
|
|
66
|
+
const index_build_1 = require("../db/index-build");
|
|
54
67
|
const scanner_1 = require("../utils/scanner");
|
|
55
68
|
const ast_1 = require("../utils/ast");
|
|
56
69
|
const edit_1 = require("../utils/edit");
|
|
70
|
+
const tokenize_1 = require("../utils/tokenize");
|
|
71
|
+
const version_1 = require("../utils/version");
|
|
57
72
|
const staging_1 = require("../db/staging");
|
|
73
|
+
const activity_2 = require("../db/activity");
|
|
58
74
|
const analyze_1 = require("../db/analyze");
|
|
75
|
+
const feedback_1 = require("../db/feedback");
|
|
76
|
+
const edges_1 = require("../db/edges");
|
|
59
77
|
const workflow_import_1 = require("../db/workflow-import");
|
|
78
|
+
const embedder_1 = require("../db/embedder");
|
|
60
79
|
// ─── Port: devs→D(4)E(5)=45 + mind→M(13)=13 → 4513 ─────────────────────────
|
|
61
80
|
exports.DEVSMIND_PORT = 4513;
|
|
62
81
|
/**
|
|
@@ -67,21 +86,23 @@ exports.DEVSMIND_PORT = 4513;
|
|
|
67
86
|
* `description` (resent every call), this is easy for a long session to lose
|
|
68
87
|
* track of too, so it should carry only what's genuinely cross-cutting.
|
|
69
88
|
*/
|
|
70
|
-
exports.DEVSMIND_INSTRUCTIONS = `DevsMind is this team's persistent shared code memory — not a personal tool you reach for only when asked to search something. Every teammate's AI agent, in every session, reads from the SAME graph you are about to write to. There is no "your copy."
|
|
71
|
-
|
|
72
|
-
If you skip recording a change, you are not skipping a formality. You are leaving the whole team's graph stale for every other AI agent that queries this code later — tomorrow, on a different task, in a different session. And the reasoning behind your change (why it was made, what ticket drove it, what was broken before, what you tried and rejected) exists ONLY in this conversation, right now. It is not in the diff. It is not in the commit message. If it isn't captured this turn, it is gone forever — no reindex, no log, no git blame can recover it later.
|
|
73
|
-
|
|
74
|
-
Non-negotiable workflow:
|
|
75
|
-
1.
|
|
76
|
-
2.
|
|
77
|
-
3.
|
|
78
|
-
4. Before
|
|
79
|
-
5.
|
|
80
|
-
6.
|
|
81
|
-
7.
|
|
82
|
-
8.
|
|
83
|
-
9.
|
|
84
|
-
|
|
89
|
+
exports.DEVSMIND_INSTRUCTIONS = `DevsMind is this team's persistent shared code memory — not a personal tool you reach for only when asked to search something. Every teammate's AI agent, in every session, reads from the SAME graph you are about to write to. There is no "your copy."
|
|
90
|
+
|
|
91
|
+
If you skip recording a change, you are not skipping a formality. You are leaving the whole team's graph stale for every other AI agent that queries this code later — tomorrow, on a different task, in a different session. And the reasoning behind your change (why it was made, what ticket drove it, what was broken before, what you tried and rejected) exists ONLY in this conversation, right now. It is not in the diff. It is not in the commit message. If it isn't captured at commit_changes this turn, it is gone forever — no reindex, no log, no git blame can recover it later.
|
|
92
|
+
|
|
93
|
+
Non-negotiable workflow:
|
|
94
|
+
1. Call start_session once, before your first WRITE of the conversation (edit_node/commit_changes and the other mutating tools). It mints a session_id that every write REQUIRES — it ties a request's edits together on the local Activity log and makes them revertable as a unit — and every response echoes it back so it stays in front of you, including after a context compaction. Read-only tools (search_nodes, get_node_code, list_nodes, and the other getters) do NOT need it: search and read freely from the very first call. Never invent a session_id yourself; if a write errors saying session_id is required, start_session was skipped — call it now, then retry. If you are resuming a conversation that already called start_session earlier (visible in the reloaded history), reuse that same session_id instead of starting a new one.
|
|
95
|
+
2. Before any filesystem search, grep, or file read: call search_nodes FIRST — it is now the one call for both "find this node" AND "find where X lives across files", so you should not need an external grep. Two inputs, pass either or both: a natural-language query (a real phrase — drives meaning-matching, so "authentication" finds a node described only as "sign-in") and/or pattern, a REAL regex used exactly as you'd give it to grep (e.g. "heartRed|onLikeTap|item\.liked" — nothing is re-escaped or split for you). Pattern-only is a precision mode: exact grep + code-body matches, no semantic blur. It returns two buckets: nodes (the indexed graph — the primary answer for "which function/class", with a true nodes_total before the top-20 cap) and files (a real grep of every repo, or just the path scope if given — the answer for things the graph does not index: CSS, JSON, config, .env, markup, wiring like "where is CORS configured" or "what mounts this middleware" — each sample line reports which function/class it falls inside, and files_total tells you honestly whether there's more than the page shown; pass a bigger offset for the next page). Triage by each node's confidence and relevance, NOT by which name looks right to you — confidence reflects how many independent layers corroborated the hit, which your own reading of a name cannot. Lockfiles and build artifacts are excluded by default, so what comes back is real source. If the response carries a compacted field, it was trimmed to fit and says exactly what was dropped — all counts stay exact, and compact:false gets you the untrimmed payload. It only returns real evidence — genuinely-absent things come back empty with a hint; if so, retry once with a different pattern/query before concluding it is not there. Only drop to a manual grep/read if search_nodes itself says truncated, or you need full file context after it points you at the file.
|
|
96
|
+
3. To read one function/class: call get_node_code instead of opening the file, and do not follow it with a file read for context — this is the ONE node-read call, not a lean summary. It already includes the file's imports, the node's own name/type/signature/description, up to 20 named callers AND callees per direction (with exact uses/used_by counts even when the lists are capped), and up to 40 other declarations from the same file (file_outline — this is how you tell "was this renamed?" or "what else lives here" without opening the file). Reach further in the SAME call instead of a second tool: graph_depth + graph_direction walks the transitive graph past the direct neighbors already included (add graph_code:true for a whole call flow's source in one round trip — if some nodes' code doesn't fit the budget they are named in graph.code_omitted_node_ids, so fetch exactly those rather than re-running blind), and history:"full" returns every revision with diffable edits, pageable with history_limit/history_offset. Every capped section is honest about it (*_truncated, *_hint) — a hint means more is reachable in this same call, not a dead end.
|
|
97
|
+
4. Before touching any function's signature: get_node_code already includes its direct callers by name (used_by_nodes) — for the FULL transitive blast radius, pass graph_direction:"in" with a graph_depth of 2-3 in that same call. Git shows you what changed; it never shows you what depends on it. Find out before you break something, not after.
|
|
98
|
+
5. Before refactoring: get_node_code already includes the last 3 changes' reasoning by default — for the full revision trail with diffable before/after edits, pass history:"full" in that same call. Git blame tells you who and when; it never tells you why. The actual decision context only exists here.
|
|
99
|
+
6. Write EVERY file with edit_node — .ts, .vue, .css, .json, .xml, .md, anything — and never your editor's own edit/write tools. There is no second write tool: edit_node is the only one. It takes file_path + old_string + new_string exactly like an ordinary edit tool and never refuses a file type; to create a file that doesn't exist yet, pass old_string: "" and the whole file as new_string. Because it knows where your text landed, it works out which function/class you changed automatically: no node_id to look up, no code_snapshot to send back. It answers with every caller of what you changed. The graph only ever holds source code (functions/classes/logic) though — writes landing outside any function (markup, config, an import) get no graph node, normal and expected, not a failure — but the whole-file change is still staged for the local activity log, so commit_changes makes it revertable there like any other edit.
|
|
100
|
+
7. commit_changes REQUIRES a message AND a reasoning — the call fails without either. message is the user's request, verbatim, that led to this commit; it builds a local, private, never-pushed activity log (devsmind view → Activity) grouping your work by request and letting the user revert a request as a whole. Pass the exact same text again on a later commit that's still answering the same request — that merges them into one entry instead of splitting it. reasoning (what_changed/why/goal) is ONE object covering everything staged since the last commit — a commit is one logical change, so it gets one why, recorded against every node it touches, not one per edit_node call.
|
|
101
|
+
8. commit_changes also REFUSES any batch containing a brand-NEW node with no description — a description is what makes that node findable later by a natural-language search_nodes query instead of only by its exact identifier. If edit_node just created a node, call add_description with it (1-3 sentences of what it does and the domain concepts involved — never a restatement of the name) before commit_changes will accept it. Nothing staged is lost by the refusal; retry the same commit_changes call once it's described. Existing nodes are never gated by this — only ones new this commit.
|
|
102
|
+
9. A workflow is a named log of how ONE piece of functionality grew, across many sessions — read it to learn how the code got this way. start_session tells you if there is a recent one worth continuing; otherwise, when starting work that might belong to an existing multi-session feature, call workflow_list. If a description matches, ask the user before continuing it, then workflow_bind to attach THIS session. Binding is local to you: it never moves, pauses, or steals anyone else's workflow, and two sessions can work different ones at the same time.
|
|
103
|
+
10. Once bound, commit_changes logs a step for you automatically — you do NOT need workflow_add_step for ordinary code work. Call it for the thing a commit cannot express: a DECISION OR RESEARCH FINDING THAT CHANGED NO CODE ("evaluated X, rejected it because Y"), attaching the docs behind it via doc_paths. That is the one kind of knowledge nothing else keeps — git has the diff, history has the per-node reasoning, but neither records what was considered and rejected. If you did work while unbound, or on the wrong workflow, workflow_sync attaches it afterwards from your local activity log: it previews first and only writes when you pass confirm:true, so nothing has to be got right in the moment.
|
|
104
|
+
11. commit_changes also REQUIRES a feedback object (5 fields) — this is the only channel that improves DevsMind over time, so answer it for real, not as a formality. Before writing "none" on any field, actually check: did anything in THIS task take an extra tool call, a guess, a re-read, or a wrong turn? There almost always is something, even on an easy task — a specific one-line answer with evidence (file:line) is far more useful than a reflexive "none". "none" is correct only when you genuinely paid attention and nothing applies. Noticed something worth reporting but aren't committing right now (or don't want to wait until you are)? Call add_feedback directly — same 5 categories, but any one or more, nothing required, no commit needed. Passing evidence (file + snippet) on a graph_problem/edge_problem gets it verified fresh at call time and marked confirmed instead of suspected.`;
|
|
105
|
+
// Shared node-type taxonomy description, reused by update_history and add_description.
|
|
85
106
|
const NODE_TYPE_DESCRIPTION = 'The type of node. Be highly specific and framework-aware. Choose from the taxonomy below (or use a custom value if nothing fits).\n\n' +
|
|
86
107
|
'UNIVERSAL: function | method | class | abstract_class | interface | type_alias | enum | constant | variable | module | namespace | decorator\n\n' +
|
|
87
108
|
'NESTJS: nest_module | nest_controller | nest_service | nest_provider | nest_guard | nest_interceptor | nest_pipe | nest_filter | nest_decorator | nest_middleware | nest_gateway | nest_resolver | nest_schema | nest_dto\n\n' +
|
|
@@ -111,8 +132,32 @@ const NODE_TYPE_DESCRIPTION = 'The type of node. Be highly specific and framewor
|
|
|
111
132
|
'SCRIPTS: build_script | migration_script | seed_script\n' +
|
|
112
133
|
'TESTS: test_suite | test_case | test_helper | mock | fixture\n' +
|
|
113
134
|
'UTILITY: util_function | helper | transformer | validator | formatter';
|
|
135
|
+
// Shared `description` field schema, reused by edit_node and add_description. This is what
|
|
136
|
+
// makes search_nodes findable by natural language — an identifier alone is a handful of words;
|
|
137
|
+
// this is where the domain vocabulary (login/auth/sign-in, cart/basket, ...) actually lives.
|
|
138
|
+
const DESCRIPTION_FIELD_SCHEMA = {
|
|
139
|
+
type: 'string',
|
|
140
|
+
description: '1-3 sentences of PURPOSE, not a restatement of the name — what this does and the domain concepts involved, using the words a developer would actually search by. "verifyCredentials verifies credentials" is rejected: it adds no vocabulary beyond the identifier itself. "Checks a user\'s email and password against stored hashes during login/sign-in, issuing a session token on success" is the shape wanted — it surfaces user, email, password, login, sign-in, session, token, none of which appear in the identifier.'
|
|
141
|
+
};
|
|
114
142
|
// Cache database connections by their resolved path to avoid re-opening constantly
|
|
115
143
|
const dbCache = new Map();
|
|
144
|
+
/**
|
|
145
|
+
* The single project this server process is bound to, resolved ONCE at startup from `devsmind
|
|
146
|
+
* start`'s `--path` (or auto-detected from its cwd). When set, the server is "stateful" in the
|
|
147
|
+
* only sense that matters to a caller: it already knows which brain it serves, so every tool
|
|
148
|
+
* stops requiring a `devmind_path` arg and `resolveDevmindPath` short-circuits to this. Left null
|
|
149
|
+
* only for the legacy/unbound modes (in-process tests, or a server started somewhere with no
|
|
150
|
+
* brain to auto-detect), where the old per-call `devmind_path` behavior is preserved unchanged.
|
|
151
|
+
*/
|
|
152
|
+
let boundDevmindPath = null;
|
|
153
|
+
/** Binds this process to one project's `.devmind` dir. Called once at server startup. */
|
|
154
|
+
function bindDevmindPath(devmindPath) {
|
|
155
|
+
boundDevmindPath = path.resolve(devmindPath);
|
|
156
|
+
}
|
|
157
|
+
/** The bound project path, or null if this server is running unbound (tests / legacy). */
|
|
158
|
+
function getBoundDevmindPath() {
|
|
159
|
+
return boundDevmindPath;
|
|
160
|
+
}
|
|
116
161
|
// Walk up from a start directory to find a .devmind folder containing config.json
|
|
117
162
|
function findDevmindDir(startDir) {
|
|
118
163
|
let current = path.resolve(startDir);
|
|
@@ -128,6 +173,12 @@ function findDevmindDir(startDir) {
|
|
|
128
173
|
}
|
|
129
174
|
// Resolve devmind_path from args, falling back to auto-detect from cwd
|
|
130
175
|
function resolveDevmindPath(rawPath) {
|
|
176
|
+
// Bound (stateful) server: it already knows which brain it serves, resolved once at startup.
|
|
177
|
+
// Whatever `devmind_path` a caller still sends is ignored — a single-project server has exactly
|
|
178
|
+
// one correct answer, and honoring a stray arg would just reintroduce the cross-project mistakes
|
|
179
|
+
// this binding exists to remove.
|
|
180
|
+
if (boundDevmindPath)
|
|
181
|
+
return boundDevmindPath;
|
|
131
182
|
const given = rawPath != null && String(rawPath) !== 'undefined' ? String(rawPath).trim() : '';
|
|
132
183
|
if (given) {
|
|
133
184
|
const resolved = path.resolve(given);
|
|
@@ -161,6 +212,104 @@ function requireStr(args, field, tool) {
|
|
|
161
212
|
}
|
|
162
213
|
return String(v);
|
|
163
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Coerce an agent-supplied number into a sane range, falling back when it isn't one at all.
|
|
217
|
+
*
|
|
218
|
+
* Module-scoped rather than local to a handler because the alternative has already bitten us:
|
|
219
|
+
* this used to live inside `case 'get_node_code'`'s `if (live.exists)` branch, so every OTHER
|
|
220
|
+
* numeric param was left on a bare `Number(...)`. That is not a cosmetic gap — `Number('abc')`
|
|
221
|
+
* is `NaN`, and `NaN` poisons whatever it flows into. A budget of `NaN` makes every
|
|
222
|
+
* `spent + len > budget` comparison false, i.e. an UNLIMITED budget; a `limit` of `NaN` makes
|
|
223
|
+
* `slice(0, NaN)` return an empty array, which reads to the agent as "nothing found" while the
|
|
224
|
+
* sibling `*_total` field says otherwise. `??` does not catch either case — only an explicit
|
|
225
|
+
* `Number.isFinite` check does.
|
|
226
|
+
*/
|
|
227
|
+
function clampInt(v, fallback, min, max) {
|
|
228
|
+
if (v === undefined)
|
|
229
|
+
return fallback;
|
|
230
|
+
const n = Math.trunc(Number(v));
|
|
231
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
232
|
+
}
|
|
233
|
+
/** Node-count safety valve for a graph embedded in a `get_node_code` response — deliberately far
|
|
234
|
+
* below `getGraph`'s own 500 default, since this graph rides along with code, imports, neighbors
|
|
235
|
+
* and history rather than being the whole payload. */
|
|
236
|
+
const GRAPH_MAX_NODES = 120;
|
|
237
|
+
/**
|
|
238
|
+
* Default char budget for `graph_code` on the `get_node_code` path, well under `getGraph`'s own
|
|
239
|
+
* 60000 fallback for the same reason `GRAPH_MAX_NODES` is: it is one section of a composite
|
|
240
|
+
* response, not the response. 60000 chars is ~15k tokens, which on an ordinary 3-hop chain
|
|
241
|
+
* overflowed the client's inline limit — the response then got spilled to a file that ALSO
|
|
242
|
+
* truncated on read, turning the tool's headline feature into a dead end. Passing this
|
|
243
|
+
* explicitly means `getGraph`'s 60000 only ever applies to the legacy `get_node_graph` caller.
|
|
244
|
+
*/
|
|
245
|
+
const GRAPH_CODE_BUDGET_DEFAULT = 24_000;
|
|
246
|
+
/**
|
|
247
|
+
* Serialized size past which a `search_nodes` response gets trimmed. A heuristic on characters,
|
|
248
|
+
* not on the client's real token limit — which we cannot see. It does not make a dead end
|
|
249
|
+
* impossible; it makes one much less likely, and (via the `compacted` note) never silent. The
|
|
250
|
+
* failure it targets: a ~56KB result exceeded the client's inline cap, spilled to a file, and
|
|
251
|
+
* that file then truncated on read too, leaving the agent hand-writing regexes against a
|
|
252
|
+
* single-line JSON blob to recover fields.
|
|
253
|
+
*/
|
|
254
|
+
const SEARCH_COMPACT_THRESHOLD = 24_000;
|
|
255
|
+
const COMPACT_NOTE_TIER1 = 'Response was trimmed to fit: per-file match_counts, matched_terms, aliases and created_at were dropped, and sample lines were cut to 2 per file/node at 200 chars. All COUNTS (nodes_total, files_total) are exact and untrimmed. Pass compact:false for the full payload, or narrow with path/limit/offset.';
|
|
256
|
+
const COMPACT_NOTE_TIER2 = 'Response was trimmed hard to fit: this is a triage list only — sample lines and code_matches were dropped entirely. All COUNTS (nodes_total, files_total) are exact and untrimmed. Pick the nodes that matter from confidence/relevance and call get_node_code on them, or re-run narrowed with path/limit for the evidence lines.';
|
|
257
|
+
/** Default page size for `list_nodes`. Sized so a page of full node rows (description included)
|
|
258
|
+
* comfortably clears the threshold above, since discovery is usually a scan-and-pick, not a dump. */
|
|
259
|
+
const LIST_NODES_DEFAULT_LIMIT = 100;
|
|
260
|
+
/** Max characters of reasoning copied onto a workflow step — a timeline of 200 steps has to stay
|
|
261
|
+
* readable in one response, and the full text is always still on the history rows. */
|
|
262
|
+
const STEP_REASONING_CAP = 2000;
|
|
263
|
+
/**
|
|
264
|
+
* The part of a commit's reasoning worth carrying onto a workflow step: WHY it was done, what it
|
|
265
|
+
* was for, and what was decided.
|
|
266
|
+
*
|
|
267
|
+
* Deliberately not `formatReasoning`, which emits all eight labels unconditionally — including the
|
|
268
|
+
* usually-empty `Requirement:`/`Previous state:` and a `Developer:`/`Model:` pair already recorded
|
|
269
|
+
* on both the history row and the activity message. On a timeline those are pure noise repeated
|
|
270
|
+
* once per step. `what_changed` is skipped too: it is what `summary` already says.
|
|
271
|
+
*/
|
|
272
|
+
function workflowReasoningText(reasoning) {
|
|
273
|
+
if (typeof reasoning === 'string')
|
|
274
|
+
return reasoning.slice(0, STEP_REASONING_CAP) || undefined;
|
|
275
|
+
if (!reasoning || typeof reasoning !== 'object')
|
|
276
|
+
return undefined;
|
|
277
|
+
const r = reasoning;
|
|
278
|
+
const parts = [
|
|
279
|
+
r.why ? `Why: ${String(r.why)}` : '',
|
|
280
|
+
r.goal ? `Goal: ${String(r.goal)}` : '',
|
|
281
|
+
r.decision ? `Decision: ${String(r.decision)}` : ''
|
|
282
|
+
].filter(Boolean);
|
|
283
|
+
return parts.length ? parts.join('\n').slice(0, STEP_REASONING_CAP) : undefined;
|
|
284
|
+
}
|
|
285
|
+
const LIST_NODES_COMPACT_NOTE = 'Page was trimmed to fit: only id, name, type and file_path are shown per node. `total` is exact. Call get_node_code on whichever node you want the details of, or re-request a smaller page with a lower limit.';
|
|
286
|
+
const COMPACT_NOTE_FORCED = 'Compact requested: triage fields only — no sample lines or code_matches. All COUNTS are exact. Call get_node_code on whichever node you pick, or re-run with compact:false for the full payload.';
|
|
287
|
+
/**
|
|
288
|
+
* The "verify before write" gate every batch graph-fix correction tool goes through — evidence
|
|
289
|
+
* captured weeks earlier by a working agent may have gone stale (the file moved, the line no
|
|
290
|
+
* longer says what it said), and a correction tool must never trust a claim it can't still check.
|
|
291
|
+
* Cheap by design (existence + substring, not a full re-resolve): a full AST re-resolution would
|
|
292
|
+
* be circular — if the resolver could already prove the link, this correction tool wouldn't be
|
|
293
|
+
* needed in the first place.
|
|
294
|
+
*/
|
|
295
|
+
function verifyEvidence(evidenceFile, evidenceSnippet) {
|
|
296
|
+
if (!fs.existsSync(evidenceFile)) {
|
|
297
|
+
return { ok: false, reason: `evidence file no longer exists on disk: ${evidenceFile}` };
|
|
298
|
+
}
|
|
299
|
+
if (evidenceSnippet) {
|
|
300
|
+
let content;
|
|
301
|
+
try {
|
|
302
|
+
content = fs.readFileSync(evidenceFile, 'utf-8');
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return { ok: false, reason: `could not read evidence file: ${err.message}` };
|
|
306
|
+
}
|
|
307
|
+
if (!content.includes(evidenceSnippet)) {
|
|
308
|
+
return { ok: false, reason: `evidence snippet no longer found in ${evidenceFile} — it may have gone stale since it was reported; re-verify against the current code before retrying` };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
164
313
|
function getDatabase(devmindPath) {
|
|
165
314
|
const dbFile = path.join(devmindPath, 'brain.db');
|
|
166
315
|
if (!dbCache.has(dbFile)) {
|
|
@@ -168,6 +317,11 @@ function getDatabase(devmindPath) {
|
|
|
168
317
|
}
|
|
169
318
|
return dbCache.get(dbFile);
|
|
170
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Closes every cached DB connection (best-effort) and clears the cache. Normally only reached via
|
|
322
|
+
* the SIGINT/SIGTERM shutdown handlers below; exported so tests can release a fixture's cached
|
|
323
|
+
* connection between cases instead of leaking a locked `brain.db` handle until the test process exits.
|
|
324
|
+
*/
|
|
171
325
|
function cleanup() {
|
|
172
326
|
for (const [dbPath, db] of dbCache.entries()) {
|
|
173
327
|
try {
|
|
@@ -179,572 +333,725 @@ function cleanup() {
|
|
|
179
333
|
}
|
|
180
334
|
dbCache.clear();
|
|
181
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Read-only tools that DON'T require a session_id. A session exists to tie a request's writes
|
|
338
|
+
* together on the local activity log and make them revertable as a unit — reads mutate nothing,
|
|
339
|
+
* so gating them buys nothing and only adds friction to the very first thing an agent does in a
|
|
340
|
+
* conversation (usually a search). Their handlers never touch `sessionId`, verified case-by-case;
|
|
341
|
+
* they neither get session_id injected into their schema nor get rejected when it's absent.
|
|
342
|
+
*
|
|
343
|
+
* `get_activity_log` is here too, but for a subtler reason: it declares its OWN optional
|
|
344
|
+
* `session_id` (a filter — "show me just this session's activity"), so exempting it stops the
|
|
345
|
+
* injection loop from force-promoting that optional filter into a required gate.
|
|
346
|
+
*
|
|
347
|
+
* NOTE: every entry here is a read. Adding a WRITE tool to this set would let it run without a
|
|
348
|
+
* session and silently drop its edits out of the revert/grouping model — do not.
|
|
349
|
+
*
|
|
350
|
+
* `get_node_history`, `get_node_graph`, `search_decisions`, `get_orphaned_nodes` are retired —
|
|
351
|
+
* unadvertised in ListTools, folded into `get_node_code`/`search_nodes`/`analyze_graph`. They
|
|
352
|
+
* MUST stay in this set anyway: the runtime session gate below consults it for every call
|
|
353
|
+
* including unlisted ones, so removing them here would make their still-live retained handlers
|
|
354
|
+
* start failing on a missing session_id for any direct/legacy caller.
|
|
355
|
+
*/
|
|
356
|
+
const SESSION_EXEMPT_READ_TOOLS = new Set([
|
|
357
|
+
'list_nodes',
|
|
358
|
+
'get_node_code',
|
|
359
|
+
'get_node_history',
|
|
360
|
+
'get_node_graph',
|
|
361
|
+
'search_nodes',
|
|
362
|
+
'search_decisions',
|
|
363
|
+
'get_orphaned_nodes',
|
|
364
|
+
'get_visualizer_url',
|
|
365
|
+
'read_graph_feedback',
|
|
366
|
+
'workflow_get_context',
|
|
367
|
+
// `workflow_search`/`workflow_read_artifact`/`workflow_get_steps` are retired and their handlers
|
|
368
|
+
// are gone, so they no longer belong here. `workflow_list` is NOT exempt any more either: it
|
|
369
|
+
// reports `bound_workflow_id`, which is a per-session fact — without a session_id it could only
|
|
370
|
+
// ever answer null, which reads as "you are on nothing" rather than "I cannot tell".
|
|
371
|
+
'get_activity_log'
|
|
372
|
+
]);
|
|
182
373
|
/**
|
|
183
374
|
* Creates and wires up a DevsMind MCP Server instance.
|
|
184
|
-
*
|
|
375
|
+
* When the process is bound to one project (`bindDevmindPath`, the normal `devsmind start` path),
|
|
376
|
+
* every tool serves that brain and no `devmind_path` arg is required or advertised. Unbound (the
|
|
377
|
+
* in-process test path, or a server started with no brain to auto-detect), it falls back to the
|
|
378
|
+
* legacy behavior: each call carries `devmind_path` and opens the db from there.
|
|
379
|
+
* Exported so tests can drive tools in-process (e.g. via an InMemoryTransport pair) without
|
|
380
|
+
* binding a real port.
|
|
185
381
|
*/
|
|
186
382
|
function createMcpServer() {
|
|
187
|
-
const server = new index_js_1.Server({ name: 'devsmind-server', version:
|
|
383
|
+
const server = new index_js_1.Server({ name: 'devsmind-server', version: version_1.DEVSMIND_VERSION }, {
|
|
188
384
|
capabilities: { tools: {} },
|
|
189
385
|
instructions: exports.DEVSMIND_INSTRUCTIONS
|
|
190
386
|
});
|
|
191
387
|
// ── Tool Definitions ────────────────────────────────────────────────────────
|
|
192
388
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
description: 'List all nodes matching optional type and file path filters. Useful to discover all entities in a component, package, or directory.',
|
|
216
|
-
inputSchema: {
|
|
217
|
-
type: 'object',
|
|
218
|
-
properties: {
|
|
219
|
-
devmind_path: {
|
|
220
|
-
type: 'string',
|
|
221
|
-
description: 'Absolute path to the .devmind directory'
|
|
222
|
-
},
|
|
223
|
-
type: {
|
|
224
|
-
type: 'string',
|
|
225
|
-
description: 'Optional filter by exact node type (e.g. nest_controller, react_component, function)'
|
|
226
|
-
},
|
|
227
|
-
file_path: {
|
|
228
|
-
type: 'string',
|
|
229
|
-
description: 'Optional filter by file path substring (e.g. "src/components" or specific file name)'
|
|
230
|
-
},
|
|
231
|
-
include_deprecated: {
|
|
232
|
-
type: 'boolean',
|
|
233
|
-
description: 'Optional flag to include deprecated nodes (default: false)'
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
required: ['devmind_path']
|
|
237
|
-
}
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
name: 'get_node_code',
|
|
241
|
-
description: "Get a single node's CURRENT source code, parsed live from its file on disk — token-efficient, since it returns only that function/class/route rather than the whole file. Call this instead of reading a file whenever you need one specific entity: reading the raw file instead means the graph never learns you looked at it, so drift between what's recorded and what's actually on disk goes undetected. Response fields: `source: \"live\"` means the code was read from disk and is current. `source: \"cached\"` means the symbol could not be located in its file (not a TS/JS file, or it was renamed/moved/deleted) so a possibly-stale cached snapshot was returned — verify it against the file before relying on it. `snapshot_outdated: true` means the stored graph has drifted from disk. If you're about to edit this node anyway, an edit_node call re-syncs it as a side effect. To force a resync with no real code change, edit_node can't help (it requires old_string to actually differ from new_string) — use stage_change instead, passing the current on-disk code as code_snapshot, then commit_changes. To fetch a whole call flow at once, prefer get_node_graph with include_code instead of calling this repeatedly.",
|
|
242
|
-
inputSchema: {
|
|
243
|
-
type: 'object',
|
|
244
|
-
properties: {
|
|
245
|
-
devmind_path: {
|
|
246
|
-
type: 'string',
|
|
247
|
-
description: 'Absolute path to the .devmind directory'
|
|
248
|
-
},
|
|
249
|
-
node_id: {
|
|
250
|
-
type: 'string',
|
|
251
|
-
description: 'Unique identifier for the node'
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
required: ['devmind_path', 'node_id']
|
|
255
|
-
}
|
|
256
|
-
},
|
|
257
|
-
// NOTE: `update_history`, `add_node`, and `add_connection` are intentionally NOT listed
|
|
258
|
-
// here. They are deprecated in favour of `stage_change` + `commit_changes` (to avoid
|
|
259
|
-
// confusing the AI with overlapping write tools), but their handlers are retained below
|
|
260
|
-
// so any direct/legacy call still works.
|
|
261
|
-
// ────────────────── Indexing tools ─────────────────────────────────────────
|
|
262
|
-
{
|
|
263
|
-
name: 'index_start',
|
|
264
|
-
description: 'Initialize an indexing session. Scans all configured repos, counts files, creates a scratchpad to track progress. Returns the full file list per repo so the AI can begin reading and indexing files. IMPORTANT: You must index natively in-chat using MCP tools. NEVER write or execute external scripts (like Python or custom scripts) to index files.',
|
|
265
|
-
inputSchema: {
|
|
266
|
-
type: 'object',
|
|
267
|
-
properties: {
|
|
268
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
389
|
+
const tools = [
|
|
390
|
+
{
|
|
391
|
+
name: 'start_session',
|
|
392
|
+
description: 'Call this ONCE, as the very first DevsMind call of a conversation — before any search, read, or edit. It mints a session_id that you must then pass on every other DevsMind call for the rest of this conversation (every tool requires it). Sessions are what ties a whole request\'s edits together on the local Activity log (`devsmind view` → Activity) and what makes a request revertable as one unit. Do not invent a session_id yourself and do not reuse one from a different conversation — if you are resuming a conversation that already called start_session earlier (visible in the reloaded history), reuse that same id instead of starting a new one; otherwise always start fresh.',
|
|
393
|
+
inputSchema: {
|
|
394
|
+
type: 'object',
|
|
395
|
+
properties: {
|
|
396
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
397
|
+
label: { type: 'string', description: 'Optional short human-readable name for this session (e.g. what the user asked for), shown on the Activity page.' }
|
|
398
|
+
},
|
|
399
|
+
required: ['devmind_path']
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
name: 'list_nodes',
|
|
404
|
+
description: 'Enumerate the nodes matching optional type and file-path filters — the "what exists here" call for a component, package, or directory (use search_nodes when the question is "find the thing that does X"). PAGED: it answers with `{nodes, total, offset}`, where `total` is the TRUE number of matches and `nodes` is one page of at most `limit` (default 100). If `total` is larger than what you got back, `truncated` and a `hint` say so and name the exact next call — never read a short page as "that is all of it". Prefer narrowing with `type`/`file_path` over paging through a whole repo. A page that is still oversized comes back with only id/name/type/file_path per node plus a `compacted` note; `total` stays exact regardless.',
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
properties: {
|
|
408
|
+
devmind_path: {
|
|
409
|
+
type: 'string',
|
|
410
|
+
description: 'Absolute path to the .devmind directory'
|
|
269
411
|
},
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
{
|
|
274
|
-
name: 'index_checkpoint',
|
|
275
|
-
description: 'Save current indexing progress to the scratchpad. Call this every ~10 files so progress survives a context reset.',
|
|
276
|
-
inputSchema: {
|
|
277
|
-
type: 'object',
|
|
278
|
-
properties: {
|
|
279
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
280
|
-
last_file_indexed: { type: 'string', description: 'Absolute path to the last file that was fully indexed' },
|
|
281
|
-
files_done: { type: 'number', description: 'Total files indexed so far' },
|
|
282
|
-
nodes_created: { type: 'number', description: 'Total nodes created so far' },
|
|
283
|
-
connections_created: { type: 'number', description: 'Total connections created so far' },
|
|
284
|
-
current_repo: { type: 'string', description: 'Name of the repo currently being indexed' },
|
|
285
|
-
repos_done: {
|
|
286
|
-
type: 'array',
|
|
287
|
-
items: { type: 'string' },
|
|
288
|
-
description: 'Names of repos fully indexed so far'
|
|
289
|
-
}
|
|
412
|
+
type: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: 'Optional filter by exact node type (e.g. nest_controller, react_component, function)'
|
|
290
415
|
},
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
name: 'index_continue',
|
|
296
|
-
description: 'Read the scratchpad and return exactly where indexing left off. Use this to resume after a context reset. IMPORTANT: You must index natively in-chat using MCP tools. NEVER write or execute external scripts (like Python or custom scripts) to index files.',
|
|
297
|
-
inputSchema: {
|
|
298
|
-
type: 'object',
|
|
299
|
-
properties: {
|
|
300
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
416
|
+
file_path: {
|
|
417
|
+
type: 'string',
|
|
418
|
+
description: 'Optional filter by file path substring (e.g. "src/components" or specific file name)'
|
|
301
419
|
},
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
{
|
|
306
|
-
name: 'index_complete',
|
|
307
|
-
description: 'Mark the indexing session as complete. Call this when all files in all repos have been indexed.',
|
|
308
|
-
inputSchema: {
|
|
309
|
-
type: 'object',
|
|
310
|
-
properties: {
|
|
311
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
420
|
+
include_deprecated: {
|
|
421
|
+
type: 'boolean',
|
|
422
|
+
description: 'Optional flag to include deprecated nodes (default: false)'
|
|
312
423
|
},
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
{
|
|
317
|
-
name: 'edit_node',
|
|
318
|
-
description: "Write ANY file in this project. Use this for EVERY edit AND every new file, in place of your editor's own edit/write tools — .ts, .js, .vue, .css, .json, .xml, .md, .py, anything. It never refuses a file for being the wrong type, and it works exactly like an ordinary edit tool: pass `file_path`, the exact `old_string` to find, and the `new_string` to put there. To CREATE a file that doesn't exist yet, pass `old_string: \"\"` and the whole file as `new_string` (parent directories are made for you).\n\n" +
|
|
319
|
-
"What it does that a plain edit tool cannot: it knows WHERE your text landed, so it works out which function/class you actually changed and records your `reasoning` against it automatically — no node_id to look up, no code_snapshot to send back, no follow-up stage_change call. That covers code you just added and files you just created, since the code is on disk by the time it looks. In return it tells you every CALLER of what you changed (i.e. what you may have just broken), what it calls out to, and the reasoning previously recorded against it.\n\n" +
|
|
320
|
-
"Writes that don't land inside any function — markup, config, an import line, a stylesheet — simply record nothing. That is a normal, expected outcome, not a failure: the file is still written and the response says so. So there is never a reason to reach for another edit or write tool.\n\n" +
|
|
321
|
-
"Nothing reaches the graph until commit_changes. For renames use rename_node.",
|
|
322
|
-
inputSchema: {
|
|
323
|
-
type: 'object',
|
|
324
|
-
properties: {
|
|
325
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
326
|
-
file_path: { type: 'string', description: 'The file to write. It does not need to exist yet.' },
|
|
327
|
-
old_string: { type: 'string', description: 'The exact text to replace, matched byte-for-byte including indentation. Must appear exactly once in the file unless replace_all is true. Pass "" to CREATE a file that does not exist yet.' },
|
|
328
|
-
new_string: { type: 'string', description: 'The text to put in its place — or, when creating a file, its entire contents. Pass an empty string to delete the matched text.' },
|
|
329
|
-
replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match (default false). Every occurrence is traced, so an edit hitting three functions records all three.' },
|
|
330
|
-
reasoning: {
|
|
331
|
-
type: 'object',
|
|
332
|
-
description: 'Why you are making this edit. Recorded automatically against whatever function/class the edit turns out to touch — you do not need to know which one. This is the only record of it that will ever exist: the diff shows what changed, never why. Ignored when the edit touches no code (a stylesheet, a config value).',
|
|
333
|
-
properties: {
|
|
334
|
-
what_changed: { type: 'string', description: 'Brief description of the modified code' },
|
|
335
|
-
why: { type: 'string', description: 'The reason this change was made' },
|
|
336
|
-
goal: { type: 'string', description: 'What was being achieved' },
|
|
337
|
-
requirement: { type: 'string', description: 'Ticket / issue / user request ID if applicable' },
|
|
338
|
-
previous_state: { type: 'string', description: 'What the code looked like before and why it was a problem' },
|
|
339
|
-
decision: { type: 'string', description: 'Architectural or implementation decision and why' },
|
|
340
|
-
developer: { type: 'string', description: 'Name of the developer (optional — a configured developer identity from `devsmind init` always overrides this)' },
|
|
341
|
-
model: { type: 'string', description: 'AI model name used' }
|
|
342
|
-
},
|
|
343
|
-
required: ['what_changed', 'why', 'goal']
|
|
344
|
-
},
|
|
345
|
-
session_id: { type: 'string', description: 'Session identifier to associate with this change (optional)' }
|
|
424
|
+
limit: {
|
|
425
|
+
type: 'number',
|
|
426
|
+
description: 'Max nodes in this page (default 100, max 500). `total` always reports the true match count regardless.'
|
|
346
427
|
},
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
428
|
+
offset: {
|
|
429
|
+
type: 'number',
|
|
430
|
+
description: 'Skip this many nodes before the returned page (default 0). Use with `total` to page through everything; results are stably ordered by file path then name, so pages never overlap or skip.'
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
required: ['devmind_path']
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: 'get_node_code',
|
|
438
|
+
description: "The ONE node-read call — a single function/class's CURRENT source code plus everything around it that a raw file read used to be the only way to get. Call this instead of reading a file whenever you need one specific entity: reading the raw file instead means the graph never learns you looked at it, AND misses everything this call adds on top of the raw text. The code is current and can be trusted as-is; if the symbol can no longer be found in its file (renamed/moved/deleted, or not a TS/JS file), the last known snapshot is returned instead so you still get something usable. `exists: false` means there is no code on file for this node at all.\n" +
|
|
439
|
+
"ALWAYS included, at no extra cost — do not re-open the file for any of this: `name`/`type`/`signature`/`description`/`deprecated` (the node's own metadata — `description` especially is the highest-signal field there is, a human-written summary of purpose); `imports` (the file's ES `import` lines — does NOT capture `require()`/dynamic `import()`/`export…from` re-exports — what an unqualified identifier resolves to, e.g. is `formatDate` from `date-fns` or a local util); `uses_nodes`/`used_by_nodes` (up to 20 named callees/callers per direction, with `uses`/`used_by` always reporting the TRUE total even when the list is capped — page a hub node's full list with `neighbors_offset`); `file_outline` (up to 40 OTHER declarations in this file — consts, types, sibling helpers, whether or not they're graph nodes — so you can tell 'was this renamed?' or 'what else is nearby' without opening the file); `recent_history` (the last 3 changes' reasoning ONLY, no code, since the code above already IS current).\n" +
|
|
440
|
+
"Reach further in the SAME call instead of a separate tool: `graph_depth`/`graph_direction` (1-10, default off) walks the TRANSITIVE graph past the always-included direct neighbors — use `graph_direction:\"in\"` before changing this node's signature to see the full blast radius, or `graph_direction:\"out\"` to trace a call flow; add `graph_code:true` to pull that whole flow's source in one round trip. `history:\"full\"` (default `\"recent\"`) returns every revision with diffable before/after edits, pageable with `history_limit`/`history_offset` — this is what used to be a separate get_node_history call. `history:\"none\"` skips history entirely. `file_outline:false` omits the outline. Every capped section says so honestly (`*_truncated`, `*_hint`) — a hint means there is more, reachable in this same call, not a dead end.",
|
|
441
|
+
inputSchema: {
|
|
442
|
+
type: 'object',
|
|
443
|
+
properties: {
|
|
444
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
445
|
+
node_id: { type: 'string', description: 'Unique identifier for the node' },
|
|
446
|
+
neighbors_limit: { type: 'number', description: 'Max named callers AND max named callees returned, each direction (default 20, max 200). `uses`/`used_by` counts are always exact regardless of this cap. Pass 0 to skip the name lists and get counts only — useful on a node you already know is a hub.' },
|
|
447
|
+
neighbors_offset: { type: 'number', description: 'Skip this many named neighbors (each direction) before the returned page (default 0). Pairs with `used_by_truncated`/`used_by_hint` to page a hub node\'s full caller list without a separate tool call.' },
|
|
448
|
+
graph_depth: { type: 'number', description: 'Walk the TRANSITIVE dependency graph this many hops past the node itself (default 0 = off; 1-10). Depth 1 adds nothing over the always-included direct neighbors — use 2-3 to trace a real call flow or a deep blast radius. Same traversal the old get_node_graph tool ran.' },
|
|
449
|
+
graph_direction: { type: 'string', enum: ['out', 'in', 'both'], description: '"out" = callees only, transitively (a call flow — use for tracing). "in" = callers only (impact analysis / blast radius before a signature change). "both" = neighborhood in both directions (default). Only applies when graph_depth >= 1.' },
|
|
450
|
+
graph_code: { type: 'boolean', description: 'Attach each graph node\'s current source, read live from disk (default false). graph_depth:3 + graph_direction:"out" + graph_code:true pulls an entire call flow\'s code in this same call — this is the direct replacement for get_node_graph include_code:true.' },
|
|
451
|
+
graph_code_budget: { type: 'number', description: 'Max total characters of graph-node code, spent nearest-the-root first (default 24000, max 200000). Only applies when graph_code is true. When the budget runs out, the nodes that missed out are named in `graph.code_omitted_node_ids` — fetch those with get_node_code, or re-issue with a bigger budget. Raising this is what gets you MORE code; it will not resurrect `graph.nodes_no_code_available` nodes, whose source genuinely could not be found.' },
|
|
452
|
+
history: { type: 'string', enum: ['none', 'recent', 'full'], description: '"recent" (default) = up to `history_limit` past changes\' REASONING only, no code — cheap enough to always look at. "full" = the same entries but with each revision\'s code_snapshot AND diffable before/after edits attached — the complete payload the old get_node_history tool returned, call it here before refactoring instead of a separate tool. "none" = skip history entirely.' },
|
|
453
|
+
history_limit: { type: 'number', description: 'History entries to return, newest first (default 3 for "recent", 5 for "full"; max 25). `history_count` always reports the true total regardless.' },
|
|
454
|
+
history_offset: { type: 'number', description: 'Skip this many history entries before the page (default 0) — how you reach revision 12 of 40 without an unbounded dump.' },
|
|
455
|
+
file_outline: { type: 'boolean', description: 'Every OTHER declaration in this file — name, kind, line range, node_id where one exists — up to 40, nearest this node first (default true). Answers "what else is here" and "was this renamed out from under me" without opening the file. Turn off only when genuinely token-tight; for a COMPLETE enumeration of a whole file or directory instead, use list_nodes with file_path.' }
|
|
456
|
+
},
|
|
457
|
+
required: ['devmind_path', 'node_id']
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
// NOTE: `update_history`, `add_node`, and `add_connection` are intentionally NOT listed
|
|
461
|
+
// here. They are deprecated in favour of `edit_node` + `commit_changes` (to avoid
|
|
462
|
+
// confusing the AI with overlapping write tools), but their handlers are retained below
|
|
463
|
+
// so any direct/legacy call still works.
|
|
464
|
+
// ────────────────── Indexing tools ─────────────────────────────────────────
|
|
465
|
+
{
|
|
466
|
+
name: 'index_start',
|
|
467
|
+
description: 'Start indexing this workspace. The server parses each file\'s structure itself, locally, deterministically — no LLM, so nothing is silently missed. You never extract entities and never send code back: the response already carries a first batch of nodes (with their code) that need a one-line description each. Write those with ONE add_description call, then call index_continue for the next batch. Repeat until index_continue reports no files remain, then call index_complete.',
|
|
468
|
+
inputSchema: {
|
|
469
|
+
type: 'object',
|
|
470
|
+
properties: {
|
|
471
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
472
|
+
},
|
|
473
|
+
required: ['devmind_path']
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: 'index_checkpoint',
|
|
478
|
+
description: 'Read current indexing progress — no arguments needed, the server tracks it. Reports files done/total, phase, and how many extracted nodes still need a description. Purely informational; call it any time to see where a long index stands.',
|
|
479
|
+
inputSchema: {
|
|
480
|
+
type: 'object',
|
|
481
|
+
properties: {
|
|
482
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
483
|
+
},
|
|
484
|
+
required: ['devmind_path']
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'index_continue',
|
|
489
|
+
description: 'Extract the next batch of nodes and re-serve any still-undescribed nodes from earlier batches (so a description never gets silently dropped). Call this after describing the previous batch, and again after a context reset — the server, not the AI, tracks exactly where indexing left off.',
|
|
490
|
+
inputSchema: {
|
|
491
|
+
type: 'object',
|
|
492
|
+
properties: {
|
|
493
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
494
|
+
},
|
|
495
|
+
required: ['devmind_path']
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: 'index_complete',
|
|
500
|
+
description: 'Call once every file is extracted and every node described. Resolves connections across the WHOLE graph in one resumable pass (never per-batch, since a node from an early batch can be the target of one from a much later batch) — call again if it reports `resume:true`. On completion: fills any used-but-unextracted references, vacuums the DB, and reports how many nodes still have no description.',
|
|
501
|
+
inputSchema: {
|
|
502
|
+
type: 'object',
|
|
503
|
+
properties: {
|
|
504
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
505
|
+
},
|
|
506
|
+
required: ['devmind_path']
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: 'edit_node',
|
|
511
|
+
description: "Write ANY file in this project. Use this for EVERY edit AND every new file, in place of your editor's own edit/write tools — .ts, .js, .vue, .css, .json, .xml, .md, .py, anything. It never refuses a file for being the wrong type, and it works exactly like an ordinary edit tool: pass `file_path`, the exact `old_string` to find, and the `new_string` to put there. To CREATE a file that doesn't exist yet, pass `old_string: \"\"` and the whole file as `new_string` (parent directories are made for you).\n\n" +
|
|
512
|
+
"What it does that a plain edit tool cannot: it knows WHERE your text landed, so it works out which function/class you actually changed — no node_id to look up, no code_snapshot to send back. That covers code you just added and files you just created, since the code is on disk by the time it looks. In return it tells you every CALLER of what you changed (i.e. what you may have just broken), what it calls out to, and the reasoning previously recorded against it.\n\n" +
|
|
513
|
+
"Writes that don't land inside any function — markup, config, an import line, a stylesheet — get no graph node. That is a normal, expected outcome, not a failure: the file is still written, and the whole-file change is staged for the local activity log regardless, so `commit_changes` still makes it individually revertable in `devsmind view` -> Chat. So there is never a reason to reach for another edit or write tool.\n\n" +
|
|
514
|
+
"Nothing reaches the graph — or the activity log — until commit_changes, where you give ONE `reasoning` covering everything staged since the last commit. For renames use rename_node.\n\n" +
|
|
515
|
+
"If this edit creates exactly ONE new function/class (the common case), pass `description` in this same call — you already know what you just wrote, so there is no reason to wait for commit_changes to refuse it and make a separate add_description round trip. When an edit touches more than one symbol, `description` is ignored (ambiguous which one it's for); use add_description for those after this call.",
|
|
516
|
+
inputSchema: {
|
|
517
|
+
type: 'object',
|
|
518
|
+
properties: {
|
|
519
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
520
|
+
file_path: { type: 'string', description: 'The file to write. It does not need to exist yet.' },
|
|
521
|
+
old_string: { type: 'string', description: 'The exact text to replace, matched byte-for-byte including indentation. Must appear exactly once in the file unless replace_all is true. Pass "" to CREATE a file that does not exist yet.' },
|
|
522
|
+
new_string: { type: 'string', description: 'The text to put in its place — or, when creating a file, its entire contents. Pass an empty string to delete the matched text.' },
|
|
523
|
+
replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match (default false). Every occurrence is traced, so an edit hitting three functions records all three.' },
|
|
524
|
+
description: {
|
|
525
|
+
...DESCRIPTION_FIELD_SCHEMA,
|
|
526
|
+
description: DESCRIPTION_FIELD_SCHEMA.description + ' Only applies when this edit touches exactly one function/class — ignored otherwise, since it would be ambiguous which touched symbol it describes.'
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
required: ['devmind_path', 'file_path', 'old_string', 'new_string']
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: 'add_description',
|
|
534
|
+
description: 'Give a natural-language description to one or more nodes that don\'t have one yet. This is what `search_nodes` matches against, so it is the ONLY way a teammate\'s natural-language question ("where do we handle X") finds this code later — an identifier alone rarely does. Three situations call for this: (1) `commit_changes` refused because a NEW node from this turn has no description — call this with exactly those node_ids, then call commit_changes again; (2) you noticed an EXISTING committed node has no description (or a poor one) and want to add/fix it directly, which writes immediately with no commit needed; (3) `index_start`/`index_continue` handed you a batch of freshly-extracted nodes — this is how you describe them, no separate indexing tool exists. Write 1-3 sentences describing PURPOSE — what it does and the domain concepts involved — using the words a developer would actually search by (e.g. mention "login"/"sign-in"/"authentication" together, not just whichever one the identifier happens to use). Never just restate the identifier: "verifyCredentials verifies credentials" is rejected — it adds no findable vocabulary.',
|
|
535
|
+
inputSchema: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
properties: {
|
|
538
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
539
|
+
descriptions: {
|
|
540
|
+
type: 'array',
|
|
541
|
+
description: 'One entry per node needing a description.',
|
|
542
|
+
items: {
|
|
361
543
|
type: 'object',
|
|
362
|
-
description: 'Structured details about this change',
|
|
363
544
|
properties: {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
requirement: { type: 'string', description: 'Ticket / issue / user request ID if applicable' },
|
|
368
|
-
previous_state: { type: 'string', description: 'What the code looked like before and why it was a problem' },
|
|
369
|
-
decision: { type: 'string', description: 'Architectural or implementation decision and why' },
|
|
370
|
-
developer: { type: 'string', description: 'Name of the developer (optional — if this project has a configured developer identity from `devsmind init`, it always overrides whatever is passed here, since the agent has no reliable way to know who the human actually is)' },
|
|
371
|
-
model: { type: 'string', description: 'AI model name used' }
|
|
545
|
+
node_id: { type: 'string', description: 'The exact node_id, as given in the commit_changes rejection or from search/list_nodes.' },
|
|
546
|
+
description: DESCRIPTION_FIELD_SCHEMA,
|
|
547
|
+
type: { type: 'string', description: '(optional) Upgrades the node\'s type — e.g. from get_node_code/index_start\'s generic AST-derived "function"/"class" to a specific framework role. ' + NODE_TYPE_DESCRIPTION }
|
|
372
548
|
},
|
|
373
|
-
required: ['
|
|
374
|
-
},
|
|
375
|
-
name: { type: 'string', description: 'Display name of the node (optional, inferred if omitted)' },
|
|
376
|
-
type: { type: 'string', description: '(optional, defaults to function) ' + NODE_TYPE_DESCRIPTION },
|
|
377
|
-
signature: { type: 'string', description: 'Parameter types + return type signature (optional)' },
|
|
378
|
-
session_id: { type: 'string', description: 'Session identifier to associate with this change (optional)' }
|
|
379
|
-
},
|
|
380
|
-
required: ['devmind_path', 'node_id', 'file_path', 'code_snapshot', 'reasoning']
|
|
381
|
-
}
|
|
382
|
-
},
|
|
383
|
-
{
|
|
384
|
-
name: 'commit_changes',
|
|
385
|
-
description: 'Flush all buffered stage_change entries in one atomic pass: creates/updates every staged node, writes every history snapshot, resolves all connections via local AST (auto-creating any referenced-but-missing nodes), then clears the buffer. If a workflow is currently active, this ALSO auto-records a step on its timeline from the staged entries\' reasoning — you do not need a separate workflow_add_step call for the normal case. Call commit_changes at natural checkpoints — after a batch of related nodes, or when switching context — not only once at the very end of a long task; a checkpoint commit can\'t be forgotten the way a single end-of-task one can. Always call it again before ending the turn if anything is still staged: an uncommitted turn leaves the whole team\'s graph stale, not just yours.',
|
|
386
|
-
inputSchema: {
|
|
387
|
-
type: 'object',
|
|
388
|
-
properties: {
|
|
389
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
390
|
-
},
|
|
391
|
-
required: ['devmind_path']
|
|
392
|
-
}
|
|
393
|
-
},
|
|
394
|
-
{
|
|
395
|
-
name: 'recheck_graph',
|
|
396
|
-
description: 'Recheck and prune spurious nodes/connections from the code graph. Removes primitives, language globals/built-ins, and nodes pointing to deleted/missing files, provided they have zero history entries (preserving change logs).',
|
|
397
|
-
inputSchema: {
|
|
398
|
-
type: 'object',
|
|
399
|
-
properties: {
|
|
400
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
401
|
-
workspace_root: { type: 'string', description: 'Absolute path to the workspace root directory to resolve relative paths and verify files exist' }
|
|
402
|
-
},
|
|
403
|
-
required: ['devmind_path', 'workspace_root']
|
|
404
|
-
}
|
|
405
|
-
},
|
|
406
|
-
{
|
|
407
|
-
name: 'get_node_history',
|
|
408
|
-
description: 'Get the full version history of a code node, including all past code snapshots and change reasoning. Git blame tells you who and when; it never tells you why — the actual decision, ticket, and what was rejected only exists here. Skip this before refactoring and you risk re-breaking a bug that was already fixed once, or undoing a decision made for a reason you never saw.',
|
|
409
|
-
inputSchema: {
|
|
410
|
-
type: 'object',
|
|
411
|
-
properties: {
|
|
412
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
413
|
-
node_id: { type: 'string', description: 'Unique identifier for the node (e.g. function or class name)' }
|
|
414
|
-
},
|
|
415
|
-
required: ['devmind_path', 'node_id']
|
|
416
|
-
}
|
|
417
|
-
},
|
|
418
|
-
{
|
|
419
|
-
name: 'get_node_graph',
|
|
420
|
-
description: 'Get a node\'s dependency graph — connected nodes and the relationships between them. Set direction:"out" AND include_code:true to pull an ENTIRE CALL FLOW in a single call: the starting node plus everything it transitively calls, each with its current source code read from disk. Use that combination whenever you are tracing how a request, endpoint, or feature flows through the codebase — it replaces a long chain of get_node_code calls with one round trip. Use direction:"in" to find every caller of a node before you change its signature — git shows you what changed, never what depends on it, so this is the only way to know what breaks before a teammate hits it. If `code_truncated` is true in the response, the character budget ran out and `nodes_without_code` nodes came back with metadata but no code — fetch those individually with get_node_code, or raise code_char_budget.',
|
|
421
|
-
inputSchema: {
|
|
422
|
-
type: 'object',
|
|
423
|
-
properties: {
|
|
424
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
425
|
-
node_id: { type: 'string', description: 'Unique identifier for the starting node (e.g. function or class name)' },
|
|
426
|
-
max_depth: { type: 'number', description: 'Maximum depth to traverse (optional, default 6). For a call flow with include_code, 2-3 is usually right.' },
|
|
427
|
-
direction: {
|
|
428
|
-
type: 'string',
|
|
429
|
-
enum: ['out', 'in', 'both'],
|
|
430
|
-
description: '"out" = only what this node calls, transitively (a call flow — use this for tracing). "in" = only what calls this node (impact analysis). "both" = the surrounding neighborhood in both directions (default).'
|
|
431
|
-
},
|
|
432
|
-
include_code: {
|
|
433
|
-
type: 'boolean',
|
|
434
|
-
description: 'Attach each node\'s current source code, read live from disk (default: false). Combine with direction:"out" to retrieve a whole call flow in one call.'
|
|
435
|
-
},
|
|
436
|
-
code_char_budget: {
|
|
437
|
-
type: 'number',
|
|
438
|
-
description: 'Max total characters of code to return, spent on the nodes nearest the starting node first (default: 60000). Only applies when include_code is true.'
|
|
549
|
+
required: ['node_id', 'description']
|
|
439
550
|
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
inputSchema: {
|
|
462
|
-
type: 'object',
|
|
463
|
-
properties: {
|
|
464
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
465
|
-
old_node_id: { type: 'string', description: 'Current unique identifier for the node' },
|
|
466
|
-
new_node_id: { type: 'string', description: 'New unique identifier for the node' },
|
|
467
|
-
new_name: { type: 'string', description: 'Optional new display name for the node' }
|
|
468
|
-
},
|
|
469
|
-
required: ['devmind_path', 'old_node_id', 'new_node_id']
|
|
470
|
-
}
|
|
471
|
-
},
|
|
472
|
-
{
|
|
473
|
-
name: 'deprecate_node',
|
|
474
|
-
description: 'Mark a code node as deprecated, removing all its connection mappings while retaining its entry and evolution history in the database. Use this if a function/class is deleted/removed from the codebase.',
|
|
475
|
-
inputSchema: {
|
|
476
|
-
type: 'object',
|
|
477
|
-
properties: {
|
|
478
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
479
|
-
node_id: { type: 'string', description: 'Unique identifier for the node to deprecate' }
|
|
480
|
-
},
|
|
481
|
-
required: ['devmind_path', 'node_id']
|
|
482
|
-
}
|
|
483
|
-
},
|
|
484
|
-
{
|
|
485
|
-
name: 'get_recent_changes',
|
|
486
|
-
description: 'Get team modifications and history updates over the last N hours, with optional downstream impact analysis.',
|
|
487
|
-
inputSchema: {
|
|
488
|
-
type: 'object',
|
|
489
|
-
properties: {
|
|
490
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
491
|
-
hours: { type: 'number', description: 'Lookback window in hours (optional, default 24)' },
|
|
492
|
-
analyze_impact: { type: 'boolean', description: 'If true, checks for downstream callers of changed nodes (optional, default true)' }
|
|
493
|
-
},
|
|
494
|
-
required: ['devmind_path']
|
|
495
|
-
}
|
|
496
|
-
},
|
|
497
|
-
{
|
|
498
|
-
name: 'get_developer_activity',
|
|
499
|
-
description: 'List recent history logs and changes made by a specific developer.',
|
|
500
|
-
inputSchema: {
|
|
501
|
-
type: 'object',
|
|
502
|
-
properties: {
|
|
503
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
504
|
-
developer: { type: 'string', description: 'Name or email of the developer' },
|
|
505
|
-
limit: { type: 'number', description: 'Maximum logs to return (optional, default 50)' }
|
|
506
|
-
},
|
|
507
|
-
required: ['devmind_path', 'developer']
|
|
508
|
-
}
|
|
509
|
-
},
|
|
510
|
-
{
|
|
511
|
-
name: 'get_changes_by_requirement',
|
|
512
|
-
description: 'List all modifications linked to a specific requirement, ticket, or issue ID.',
|
|
513
|
-
inputSchema: {
|
|
514
|
-
type: 'object',
|
|
515
|
-
properties: {
|
|
516
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
517
|
-
requirement_id: { type: 'string', description: 'The requirement, ticket, or issue ID' }
|
|
518
|
-
},
|
|
519
|
-
required: ['devmind_path', 'requirement_id']
|
|
520
|
-
}
|
|
521
|
-
},
|
|
522
|
-
{
|
|
523
|
-
name: 'search_decisions',
|
|
524
|
-
description: 'Search reasoning logs for specific architectural or implementation decisions.',
|
|
525
|
-
inputSchema: {
|
|
526
|
-
type: 'object',
|
|
527
|
-
properties: {
|
|
528
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
529
|
-
query: { type: 'string', description: 'Term or keyword to search' }
|
|
530
|
-
},
|
|
531
|
-
required: ['devmind_path', 'query']
|
|
532
|
-
}
|
|
533
|
-
},
|
|
534
|
-
// NOTE: `search_code` is intentionally NOT listed here anymore. `search_nodes` now
|
|
535
|
-
// falls back to the same code-content search automatically when the identifier
|
|
536
|
-
// match is empty, so there is no longer a reason to advertise two search tools.
|
|
537
|
-
// The handler below is retained so any direct/legacy call still works.
|
|
538
|
-
{
|
|
539
|
-
name: 'get_orphaned_nodes',
|
|
540
|
-
description: 'Find disconnected code nodes in the graph that have no incoming or outgoing connections.',
|
|
541
|
-
inputSchema: {
|
|
542
|
-
type: 'object',
|
|
543
|
-
properties: {
|
|
544
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
545
|
-
},
|
|
546
|
-
required: ['devmind_path']
|
|
547
|
-
}
|
|
548
|
-
},
|
|
549
|
-
{
|
|
550
|
-
name: 'get_visualizer_url',
|
|
551
|
-
description: 'Get local URLs to open the interactive 2D and 3D code graph visualizer pages.',
|
|
552
|
-
inputSchema: {
|
|
553
|
-
type: 'object',
|
|
554
|
-
properties: {
|
|
555
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
556
|
-
},
|
|
557
|
-
required: ['devmind_path']
|
|
558
|
-
}
|
|
559
|
-
},
|
|
560
|
-
{
|
|
561
|
-
name: 'analyze_graph',
|
|
562
|
-
description: 'Run a local, zero-token health check on the graph: god entities (high fan-in/out), circular dependency cycles, orphaned nodes, dangling edges, duplicate/case-collision ids, history missing developer attribution, empty code snapshots, spurious/built-in nodes, missing files, git-detected renames, and git-tracked code files with zero graph nodes. Purely local SQLite/filesystem/git queries — no LLM calls. Call this periodically (or when the graph feels stale/wrong) instead of guessing why context looks off. Set fix:true to auto-apply only the SAFE fixes (soft-deprecate dead nodes, remove dangling edges, migrate detected renames) — everything else is report-only and needs a human or agent decision.',
|
|
563
|
-
inputSchema: {
|
|
564
|
-
type: 'object',
|
|
565
|
-
properties: {
|
|
566
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
567
|
-
fix: { type: 'boolean', description: 'If true, applies safe automatic fixes (default: false — dry run/report only)' },
|
|
568
|
-
god_entity_threshold: { type: 'number', description: 'Connection-degree threshold to flag a god entity (default: 15)' }
|
|
569
|
-
},
|
|
570
|
-
required: ['devmind_path']
|
|
571
|
-
}
|
|
572
|
-
},
|
|
573
|
-
{
|
|
574
|
-
name: 'workflow_create',
|
|
575
|
-
description: 'Start a new persistent, cross-session workflow for a multi-day feature (e.g. "Wallet Integration"). Becomes the active workflow — call workflow_add_step as you make progress so the timeline survives session/context resets. Auto-pauses whatever workflow was previously active.',
|
|
576
|
-
inputSchema: {
|
|
577
|
-
type: 'object',
|
|
578
|
-
properties: {
|
|
579
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
580
|
-
name: { type: 'string', description: 'Short human-readable name for the feature/workflow' },
|
|
581
|
-
description: { type: 'string', description: 'Brief description of the goal — used by you (the agent) to judge whether a later task relates to this workflow' }
|
|
582
|
-
},
|
|
583
|
-
required: ['devmind_path', 'name', 'description']
|
|
584
|
-
}
|
|
585
|
-
},
|
|
586
|
-
{
|
|
587
|
-
name: 'workflow_add_step',
|
|
588
|
-
description: 'Record a step in the currently active (or specified) workflow\'s timeline — a short note of progress, linked to the history_ids already created via edit_node/stage_change + commit_changes rather than duplicating any code or reasoning. NOTE: commit_changes already auto-records a step from its staged entries whenever a workflow is active — you do NOT need to call this after every commit. Only call it directly for something a commit doesn\'t cover: a decision made without a code change, a note on what\'s still pending (pending_tasks), or a custom summary richer than the auto-generated one.',
|
|
589
|
-
inputSchema: {
|
|
590
|
-
type: 'object',
|
|
591
|
-
properties: {
|
|
592
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
593
|
-
workflow_id: { type: 'string', description: 'Workflow to add this step to (optional — defaults to the currently active workflow)' },
|
|
594
|
-
summary: { type: 'string', description: 'Short summary of what this step accomplished' },
|
|
595
|
-
pending_tasks: { type: 'string', description: 'Optional note on what is still left to do' },
|
|
596
|
-
history_ids: { type: 'array', items: { type: 'string' }, description: 'Optional history row ids (from stage_change/commit_changes results) this step covers' },
|
|
597
|
-
session_id: { type: 'string', description: 'Optional session id grouping this step with related history entries' }
|
|
598
|
-
},
|
|
599
|
-
required: ['devmind_path', 'summary']
|
|
600
|
-
}
|
|
601
|
-
},
|
|
602
|
-
{
|
|
603
|
-
name: 'workflow_pause',
|
|
604
|
-
description: 'Pauses the currently active workflow and clears the active pointer — use when switching to unrelated work, so the next workflow_list call surfaces it as resumable instead of leaving it silently abandoned.',
|
|
605
|
-
inputSchema: {
|
|
606
|
-
type: 'object',
|
|
607
|
-
properties: {
|
|
608
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
609
|
-
},
|
|
610
|
-
required: ['devmind_path']
|
|
611
|
-
}
|
|
612
|
-
},
|
|
613
|
-
{
|
|
614
|
-
name: 'workflow_resume',
|
|
615
|
-
description: 'Resumes a paused workflow, making it active again (auto-pausing whatever was active before).',
|
|
616
|
-
inputSchema: {
|
|
617
|
-
type: 'object',
|
|
618
|
-
properties: {
|
|
619
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
620
|
-
workflow_id: { type: 'string', description: 'The workflow id to resume' }
|
|
621
|
-
},
|
|
622
|
-
required: ['devmind_path', 'workflow_id']
|
|
623
|
-
}
|
|
624
|
-
},
|
|
625
|
-
{
|
|
626
|
-
name: 'workflow_list',
|
|
627
|
-
description: 'List workflows (optionally filtered by status). Call this when starting work that MIGHT relate to a paused, multi-session feature — if a description looks related to the current task, ask the user whether to resume it (workflow_resume) instead of silently starting fresh and losing its prior decision history.',
|
|
628
|
-
inputSchema: {
|
|
629
|
-
type: 'object',
|
|
630
|
-
properties: {
|
|
631
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
632
|
-
status: { type: 'string', enum: ['active', 'paused', 'completed'], description: 'Optional status filter (default: all)' }
|
|
633
|
-
},
|
|
634
|
-
required: ['devmind_path']
|
|
635
|
-
}
|
|
636
|
-
},
|
|
637
|
-
{
|
|
638
|
-
name: 'workflow_get_context',
|
|
639
|
-
description: 'Get a workflow\'s full timeline in one call — every step (in order) plus every reference artifact\'s metadata (and optionally content). Call this right after resuming a workflow to instantly regain the feature\'s full context. For large/long-running workflows, prefer workflow_get_steps (paginated) + workflow_read_artifact (per artifact) instead.',
|
|
640
|
-
inputSchema: {
|
|
641
|
-
type: 'object',
|
|
642
|
-
properties: {
|
|
643
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
644
|
-
workflow_id: { type: 'string', description: 'The workflow id to fetch context for' },
|
|
645
|
-
include_artifact_content: { type: 'boolean', description: 'If true, embeds each artifact\'s file content inline in the response (default: false). Only use for small/short workflows — large workflows should use workflow_read_artifact per artifact instead.' }
|
|
646
|
-
},
|
|
647
|
-
required: ['devmind_path', 'workflow_id']
|
|
648
|
-
}
|
|
649
|
-
},
|
|
650
|
-
{
|
|
651
|
-
name: 'workflow_add_artifact',
|
|
652
|
-
description: 'Save reference material (a spec excerpt, ticket description, API doc, search-result snippet) to a workflow — written to disk under .devmind/workflows/<id>/ and linked in the DB. Use this for material that informed the work but isn\'t part of the code graph itself.',
|
|
653
|
-
inputSchema: {
|
|
654
|
-
type: 'object',
|
|
655
|
-
properties: {
|
|
656
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
657
|
-
workflow_id: { type: 'string', description: 'The target workflow id' },
|
|
658
|
-
step_id: { type: 'string', description: 'Optional step id this artifact relates to' },
|
|
659
|
-
type: { type: 'string', description: 'Artifact type, e.g. pm_doc, api_spec, web_snippet' },
|
|
660
|
-
source_name: { type: 'string', description: 'Title/filename of the artifact source' },
|
|
661
|
-
content: { type: 'string', description: 'Text or markdown content to save' }
|
|
662
|
-
},
|
|
663
|
-
required: ['devmind_path', 'workflow_id', 'type', 'source_name', 'content']
|
|
664
|
-
}
|
|
665
|
-
},
|
|
666
|
-
{
|
|
667
|
-
name: 'workflow_sync_retroactive',
|
|
668
|
-
description: 'Backfill a workflow\'s timeline after a whole session went by without using workflow_add_step. You already have the session\'s transcript in your own context — extract the steps yourself and pass them here as structured data. This does NOT accept raw transcript text; DevsMind never runs its own LLM calls, so extraction has to happen on your side, which you can already do for free since you already read it.',
|
|
669
|
-
inputSchema: {
|
|
670
|
-
type: 'object',
|
|
671
|
-
properties: {
|
|
672
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
673
|
-
workflow_id: { type: 'string', description: 'The workflow id to sync into' },
|
|
674
|
-
steps: {
|
|
675
|
-
type: 'array',
|
|
676
|
-
description: 'Steps you extracted from the session, oldest first',
|
|
677
|
-
items: {
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
required: ['devmind_path', 'descriptions']
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
name: 'add_feedback',
|
|
558
|
+
description: 'Record feedback ON DEMAND, outside of commit_changes — for when you notice something worth reporting (a wrong/stale graph node, a missing connection, a tool that helped or didn\'t, a rough edge) but are not committing anything right now, or don\'t want to wait until you are. `commit_changes` still collects the same 5 categories as a REQUIRED part of every commit — use this instead when there is nothing to commit, or the moment you notice something is not the moment you are about to commit.\n' +
|
|
559
|
+
'Pass ANY ONE OR MORE of the five fields — unlike `commit_changes`\' `feedback`, none are required here, only that at least one is present. `graph_problem`/`edge_problem` take an object (`text` + optional `node_id` + optional `evidence`); `tools_used`/`dropped_and_why`/`devsmind_better` are plain strings, same meaning as their `commit_changes` counterparts.\n' +
|
|
560
|
+
'Evidence matters here more than in `commit_changes`: pass `evidence` (`file` + optional `line`/`snippet`) on a graph/edge problem and it is VERIFIED FRESH at call time (the file must exist; if you gave a snippet, it must still be found in it) — a verified report is marked `confirmed` and is a strong candidate for the batch graph-fix session to act on directly. Omit evidence and it is still recorded, just `suspected` (lower priority, needs corroboration). A bad/stale evidence claim is REJECTED outright, not silently downgraded — re-check against current code and retry.',
|
|
561
|
+
inputSchema: {
|
|
562
|
+
type: 'object',
|
|
563
|
+
properties: {
|
|
564
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
565
|
+
graph_problem: {
|
|
566
|
+
type: 'object',
|
|
567
|
+
description: 'A node that was wrong, stale, missing, or wrongly split/merged.',
|
|
568
|
+
properties: {
|
|
569
|
+
text: { type: 'string', description: 'What was wrong, specifically.' },
|
|
570
|
+
node_id: { type: 'string', description: 'The node this is about, if there is one exact node.' },
|
|
571
|
+
evidence: {
|
|
678
572
|
type: 'object',
|
|
573
|
+
description: 'Verified fresh at call time — see the tool description.',
|
|
679
574
|
properties: {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
575
|
+
file: { type: 'string', description: 'Absolute path to the file that shows this is real.' },
|
|
576
|
+
line: { type: 'number', description: 'Line number, if useful (not itself verified — the snippet is).' },
|
|
577
|
+
snippet: { type: 'string', description: 'Exact text that must still be found in `file` for this to verify as confirmed.' }
|
|
683
578
|
},
|
|
684
|
-
required: ['
|
|
579
|
+
required: ['file']
|
|
685
580
|
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
required: ['devmind_path', 'workflow_id', 'steps']
|
|
689
|
-
}
|
|
690
|
-
},
|
|
691
|
-
{
|
|
692
|
-
name: 'workflow_import',
|
|
693
|
-
description: 'Import existing flow/architecture docs (markdown files describing a feature — title, summary, implementation details) as paused, resumable workflows, so reference material a team already wrote lives where you already look (workflow_get_context) instead of scattered elsewhere. Re-importing the same file updates its workflow in place rather than duplicating it.',
|
|
694
|
-
inputSchema: {
|
|
695
|
-
type: 'object',
|
|
696
|
-
properties: {
|
|
697
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
698
|
-
folder_path: { type: 'string', description: 'Folder to import every .md file from (one workflow per file)' },
|
|
699
|
-
file_path: { type: 'string', description: 'A single .md file to import instead of a folder' }
|
|
581
|
+
},
|
|
582
|
+
required: ['text']
|
|
700
583
|
},
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
584
|
+
edge_problem: {
|
|
585
|
+
type: 'object',
|
|
586
|
+
description: 'A missing or wrong connection between two nodes — e.g. get_node_code reported 0 used_by for something you found a real caller of.',
|
|
587
|
+
properties: {
|
|
588
|
+
text: { type: 'string', description: 'What connection was missing or wrong, specifically.' },
|
|
589
|
+
node_id: { type: 'string', description: 'The node this is about, if there is one exact node.' },
|
|
590
|
+
evidence: {
|
|
591
|
+
type: 'object',
|
|
592
|
+
description: 'Verified fresh at call time — see the tool description.',
|
|
593
|
+
properties: {
|
|
594
|
+
file: { type: 'string', description: 'Absolute path to the file that shows this is real.' },
|
|
595
|
+
line: { type: 'number', description: 'Line number, if useful (not itself verified — the snippet is).' },
|
|
596
|
+
snippet: { type: 'string', description: 'Exact text that must still be found in `file` for this to verify as confirmed.' }
|
|
597
|
+
},
|
|
598
|
+
required: ['file']
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
required: ['text']
|
|
714
602
|
},
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
603
|
+
tools_used: { type: 'string', description: 'Which DevsMind tools actually helped and how — specific enough to be useful, not a restatement of the tool list.' },
|
|
604
|
+
dropped_and_why: { type: 'string', description: 'Something you reached for instead of a DevsMind tool (raw grep, reading a whole file, guessing) and why the DevsMind tool did not do the job.' },
|
|
605
|
+
devsmind_better: { type: 'string', description: 'One concrete way DevsMind could have made this easier — a missing field, a confusing description, a search that should have found something and did not.' }
|
|
606
|
+
},
|
|
607
|
+
required: ['devmind_path']
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
name: 'commit_changes',
|
|
612
|
+
description: 'Flush THIS SESSION\'s buffered edit_node entries in one atomic pass: creates/updates every staged node, writes every history snapshot with the ONE `reasoning` you give here, resolves all connections via local AST (auto-creating any referenced-but-missing nodes), then clears only this session\'s share of the buffer. Every entity staged since your last commit gets the SAME reasoning — a commit is one logical change, so it needs one why, not one per node. The staging buffer is shared by every session pointed at this .devmind directory, but a commit only ever touches entries YOUR session staged — another session\'s still-pending work (possibly in an unrelated file or repo) is never included and is never cleared out from under it; `other_sessions_pending` in the response tells you if any exist. If a workflow is currently active, this ALSO auto-records a step on its timeline from that reasoning — you do not need a separate workflow_add_step call for the normal case. Call commit_changes at natural checkpoints — after a batch of related nodes, or when switching context — not only once at the very end of a long task; a checkpoint commit can\'t be forgotten the way a single end-of-task one can. Always call it again before ending the turn if anything is still staged: an uncommitted turn leaves your own work out of the graph.',
|
|
613
|
+
inputSchema: {
|
|
614
|
+
type: 'object',
|
|
615
|
+
properties: {
|
|
616
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
617
|
+
message: { type: 'string', description: 'REQUIRED — the user\'s original request that led to this commit, verbatim. Powers a local, private-to-this-machine activity log (`devsmind view` → Activity) that groups your work by request and lets a request be reverted as a whole. Pass the SAME text again on a later commit that continues answering the same request — that merges them into one entry instead of two. Never pushed; nothing here reaches the shared graph.' },
|
|
618
|
+
reasoning: {
|
|
619
|
+
type: 'object',
|
|
620
|
+
description: 'REQUIRED — why every entity staged since the last commit changed. This is the only record of it that will ever exist: the diff shows what changed, never why. Recorded against EVERY node this commit touches, not just one.',
|
|
621
|
+
properties: {
|
|
622
|
+
what_changed: { type: 'string', description: 'Brief description of the change' },
|
|
623
|
+
why: { type: 'string', description: 'The reason this change was made' },
|
|
624
|
+
goal: { type: 'string', description: 'What was being achieved' },
|
|
625
|
+
requirement: { type: 'string', description: 'Ticket / issue / user request ID if applicable' },
|
|
626
|
+
previous_state: { type: 'string', description: 'What the code looked like before and why it was a problem' },
|
|
627
|
+
decision: { type: 'string', description: 'Architectural or implementation decision and why' },
|
|
628
|
+
developer: { type: 'string', description: 'Name of the developer (optional — a configured developer identity from `devsmind init` always overrides this)' },
|
|
629
|
+
model: { type: 'string', description: 'AI model name used' }
|
|
630
|
+
},
|
|
631
|
+
required: ['what_changed', 'why', 'goal']
|
|
727
632
|
},
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
633
|
+
feedback: {
|
|
634
|
+
type: 'object',
|
|
635
|
+
description: 'REQUIRED, every commit — this is the ONLY mechanism that makes DevsMind better over time instead of staying frozen at index time; nobody is reading your transcript, this field is the entire signal. Before writing "none" anywhere, actually pause and check each field against what just happened in THIS task — not "was there a catastrophe" but "was there one moment that took an extra tool call, a guess, a re-read, or a wrong turn". There almost always is one, even on an easy task, and a single honest sentence about it ("get_node_code showed 0 used_by for X but I found a real caller via grep at foo.ts:12" / "had to read the raw file because search_nodes missed the CSS class") is far more useful than a reflexive "none" — "none" is for when you genuinely paid attention and nothing applies, not the path of least resistance. A vague or fabricated entry helps nobody either — evidence (file:line) is what makes a report actionable weeks later. Never blocks or slows the commit; a thoughtful "none" is fine when it is true. `graph_problems`/`edge_problems` route to a local graph-fix queue a supervised session drains later. `tools_used`/`dropped_and_why`/`devsmind_better` route to a product-feedback log read by the DevsMind maintainers, not by any agent — `devsmind feedback` (CLI) surfaces both logs for a human to read.',
|
|
636
|
+
properties: {
|
|
637
|
+
graph_problems: { type: 'string', description: 'A node that was wrong, stale, missing, or wrongly split/merged — with evidence (file:line or similar) if you have it. Genuinely "none" if the graph was accurate for everything you touched.' },
|
|
638
|
+
edge_problems: { type: 'string', description: 'A missing or wrong connection — e.g. get_node_code reported 0 used_by for something you found a real caller of via grep/reading code. Name the caller (file:line). Genuinely "none" if every connection you checked was correct.' },
|
|
639
|
+
tools_used: { type: 'string', description: 'Which DevsMind tools actually helped this task and how — specific enough to be useful (e.g. "get_node_code with graph_depth:3 + graph_code saved reading 4 files to trace the call flow"), not a restatement of the tool list.' },
|
|
640
|
+
dropped_and_why: { type: 'string', description: 'Anything you reached for instead of a DevsMind tool (raw grep, reading a whole file, guessing) and why the DevsMind tool did not do the job. If you never once stepped outside DevsMind this task, say so — that is a real, useful "none".' },
|
|
641
|
+
devsmind_better: { type: 'string', description: 'One concrete way DevsMind could have made this task easier — a missing field, a confusing tool description, a search that should have found something and did not. Specific beats generic; "none" only if you truly cannot think of one.' }
|
|
642
|
+
},
|
|
643
|
+
required: ['graph_problems', 'edge_problems', 'tools_used', 'dropped_and_why', 'devsmind_better']
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
required: ['devmind_path', 'message', 'reasoning', 'feedback']
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: 'recheck_graph',
|
|
651
|
+
description: 'Recheck and prune spurious nodes/connections from the code graph. Removes primitives, language globals/built-ins, and nodes pointing to deleted/missing files, provided they have zero history entries (preserving change logs).',
|
|
652
|
+
inputSchema: {
|
|
653
|
+
type: 'object',
|
|
654
|
+
properties: {
|
|
655
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
656
|
+
workspace_root: { type: 'string', description: 'Absolute path to the workspace root directory to resolve relative paths and verify files exist' }
|
|
657
|
+
},
|
|
658
|
+
required: ['devmind_path', 'workspace_root']
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
// NOTE: `get_node_history` and `get_node_graph` are intentionally NOT listed here anymore.
|
|
662
|
+
// `get_node_code` absorbed both: `history:"full"` (+ history_limit/history_offset) returns
|
|
663
|
+
// exactly what get_node_history did, and `graph_depth`/`graph_direction`/`graph_code`
|
|
664
|
+
// (+ graph_code_budget) run the identical BFS get_node_graph did — direct callers/callees
|
|
665
|
+
// are now included in every get_node_code response by default, so the common case that
|
|
666
|
+
// used to need a second tool call needs none. Their handlers are retained below so any
|
|
667
|
+
// direct/legacy call still works.
|
|
668
|
+
{
|
|
669
|
+
name: 'search_nodes',
|
|
670
|
+
description: 'The ONE search call — it covers both the indexed graph AND the raw filesystem, so you never need to fall back to an external grep. Two independent inputs, pass either or both:\n' +
|
|
671
|
+
'• `query` — a natural-language phrase (e.g. "where do we handle user login"). Drives the semantic/meaning layer and BM25. Use this for a concept you can describe but don\'t have exact terms for.\n' +
|
|
672
|
+
'• `pattern` — a REAL regex, used exactly as you\'d give it to grep (alternation, escaped literals, character classes — nothing is re-escaped or split for you). Drives the file grep and code-body matching directly. Use this when you already know identifiers, an error string, or a structural shape (e.g. "heartRed|onLikeTap|item\\.liked" or "on\\w+Tap").\n' +
|
|
673
|
+
'Passing only `pattern` is a first-class PRECISION mode: the semantic layer and the exact-identifier short-circuit are both skipped (a regex has no meaning to embed), so results are exact grep + code-body matches only — nothing ranked or blurred. Passing only `query` derives a literal OR-pattern from its significant words automatically, same as before. Passing both engages everything at once. At least one is required.\n' +
|
|
674
|
+
'It returns TWO buckets:\n' +
|
|
675
|
+
'• `nodes` (PRIMARY): the indexed graph — functions/classes found by exact identifier, then by three fused rankers: word-match (BM25 over name/id/path/description/reasoning), meaning-match (vectors over descriptions, so "authentication" finds a node described only as "sign-in"), and code-body-match (your pattern appearing inside a node\'s code, with the matching lines in `code_matches`). Each node leads with `confidence` (high/medium/low), `relevance` (0-100 relative to the top hit) and `found_by` (which layers matched it) — TRIAGE ON THOSE, not on which node name reads plausibly to you. `confidence` is corroboration across independent layers, which is evidence you cannot reconstruct by eye; a name that merely looks right is the single easiest way to pick the wrong node. `nodes_total` is the TRUE count found before the top-20 cap.\n' +
|
|
676
|
+
'• `files` (LAST RESORT): a real grep of every configured repo (or just `path`, if given), ranked by relevance — this is how you find things the graph does NOT model: CSS, JSON, config, `.env`, markup, and any un-indexed code. Each entry has the file path, per-match counts, and sample matching lines — each sample line carries `symbol` when it falls inside a known function/class (the insight a plain grep can\'t give you: not just "line 87 matched" but "line 87, inside onLikeTap"). `files_total` is the TRUE count of matching files; `files_total` bigger than the number of entries returned means there\'s more — pass a bigger `offset` for the next page, don\'t assume "not there" from a capped list.\n' +
|
|
677
|
+
'Every `nodes` entry also carries drill-in hooks: `uses`/`used_by` (outgoing/incoming connection counts) and `history_count` (revision count). If you are about to change this node\'s signature or behavior and `used_by` is non-trivial, call `get_node_code` on it FIRST — its `used_by_nodes` already names the direct callers, and `graph_depth`/`graph_direction:"in"` in that same call gets the full transitive blast radius. `used_by: 0` carries a `used_by_note` when the graph could not statically prove any caller (generated bindings, dynamic dispatch) — treat that as "unverified", not "unused".\n' +
|
|
678
|
+
'Lockfiles (package-lock.json, yarn.lock, Podfile.lock, go.sum…) and build artifacts (*.min.js, *.map) are excluded by default, along with anything in this project\'s configured ignored_paths — a lockfile names every dependency in the tree, so it used to match almost any product term and crowd out the real source. `.env`, JSON and config are NOT excluded; those are what the files bucket is for. Scoping `path` straight at an excluded file returns nothing and says so in `scope_note` — read it directly rather than re-querying.\n' +
|
|
679
|
+
'If a response would be too large it is trimmed automatically and a `compacted` field says exactly what was dropped; every COUNT stays exact, so a trimmed result is never mistakable for a complete one. Pass `compact:false` to demand the full payload, or `compact:true` to ask for a lean triage list up front.\n' +
|
|
680
|
+
'It only surfaces real evidence: a search for something genuinely not in the codebase comes back with empty buckets + a `hint`, not padded guesses. `truncated: true` means the grep walk hit its time budget and results are partial.',
|
|
681
|
+
inputSchema: {
|
|
682
|
+
type: 'object',
|
|
683
|
+
properties: {
|
|
684
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
685
|
+
query: { type: 'string', description: 'Natural-language description of what you are looking for (a real phrase, e.g. "where do we handle user login"). Drives the semantic/meaning layer and BM25. Optional if `pattern` is given.' },
|
|
686
|
+
pattern: { type: 'string', description: 'A real regex, used exactly as-is (not escaped, not split) — pass the same string you would give grep, e.g. "heartRed|onLikeTap|item\\.liked". Drives the file grep and code-body matching. Optional if `query` is given; when omitted, one is derived from `query`\'s own significant words. Passing this WITHOUT `query` is a precision-only mode: exact matches, no semantic ranking.' },
|
|
687
|
+
path: { type: 'string', description: 'Optional: restrict the search to one folder or a single file (absolute path), instead of every configured repo. Rejected if it falls outside every configured repo — narrows the search space, never widens it.' },
|
|
688
|
+
case_insensitive: { type: 'boolean', description: 'Case-insensitive matching (default: true) — the equivalent of grep -i. Prefer this over an inline (?i) regex flag: JavaScript throws on the leading (?i) form most tools use.' },
|
|
689
|
+
offset: { type: 'number', description: 'Files-bucket pagination: how many matched files to skip before the returned page (default 0). Use with `files_total` to page through results beyond the default page size.' },
|
|
690
|
+
limit: { type: 'number', description: 'Files-bucket pagination: max files to return in this page (default 25, max 200).' },
|
|
691
|
+
compact: { type: 'boolean', description: 'Leave this OFF unless you have a reason. Omitted (the default) means AUTO: the full payload comes back when it fits, and is trimmed only if it would be too large — either way a `compacted` field says exactly what happened, so you are never guessing. Pass true to force a lean triage list up front (ids, names, paths, descriptions, confidence, drill-in counts — no sample lines or code_matches) when you already know you only need to pick a node. Pass false to demand the untrimmed payload regardless of size. Counts (`nodes_total`, `files_total`) are exact in every mode.' }
|
|
692
|
+
},
|
|
693
|
+
required: ['devmind_path']
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: 'rename_node',
|
|
698
|
+
description: 'Rename a code node ID (and optionally its display name), updating all its associations (connections and history).',
|
|
699
|
+
inputSchema: {
|
|
700
|
+
type: 'object',
|
|
701
|
+
properties: {
|
|
702
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
703
|
+
old_node_id: { type: 'string', description: 'Current unique identifier for the node' },
|
|
704
|
+
new_node_id: { type: 'string', description: 'New unique identifier for the node' },
|
|
705
|
+
new_name: { type: 'string', description: 'Optional new display name for the node' }
|
|
706
|
+
},
|
|
707
|
+
required: ['devmind_path', 'old_node_id', 'new_node_id']
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: 'deprecate_node',
|
|
712
|
+
description: 'Mark a code node as deprecated, removing all its connection mappings while retaining its entry and evolution history in the database. Use this if a function/class is deleted/removed from the codebase.',
|
|
713
|
+
inputSchema: {
|
|
714
|
+
type: 'object',
|
|
715
|
+
properties: {
|
|
716
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
717
|
+
node_id: { type: 'string', description: 'Unique identifier for the node to deprecate' }
|
|
718
|
+
},
|
|
719
|
+
required: ['devmind_path', 'node_id']
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
// ── Batch graph-fix session tools ─────────────────────────────────────────────────
|
|
723
|
+
// NOT for the normal working flow — these read/apply the graph-problem reports
|
|
724
|
+
// `commit_changes`'s `feedback` param accumulates over time (see feedback.ts). Meant to
|
|
725
|
+
// be run as a separate, deliberate session (days/weeks after the reports accumulate),
|
|
726
|
+
// never live during ordinary edit/commit work. Every mutating tool here is
|
|
727
|
+
// evidence-gated (re-verified against current code, not just trusted) and additive —
|
|
728
|
+
// the graph only ever gets MORE complete from these, never silently pruned.
|
|
729
|
+
{
|
|
730
|
+
name: 'read_graph_feedback',
|
|
731
|
+
description: 'Reads every unprocessed graph-problem report accumulated from commit_changes\' `feedback` param, clustered by (node_id, category) and sorted by frequency — the SAME report surfacing many times means it is worth fixing first, and one correction (e.g. record_alias) often resolves an entire cluster at once. Start a batch graph-fix session with this call. Each entry carries `confidence` ("confirmed" = evidence was given, "suspected" = none) but treat confidence as a PRIORITY signal only — always re-verify against the current code before acting, since a report may be stale by the time this runs. After acting on a cluster (or explicitly deciding it does not apply), call mark_graph_feedback_processed with its entries\' ids so a later run does not re-surface it.',
|
|
732
|
+
inputSchema: {
|
|
733
|
+
type: 'object',
|
|
734
|
+
properties: {
|
|
735
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
736
|
+
include_processed: { type: 'boolean', description: 'Include already-processed entries too (default: false — normally you only want unprocessed ones).' }
|
|
737
|
+
},
|
|
738
|
+
required: ['devmind_path']
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: 'mark_graph_feedback_processed',
|
|
743
|
+
description: 'Marks graph-feedback entries as processed by id, so a later read_graph_feedback call does not re-surface them. Call this after you have acted on a cluster (applied a fix) OR explicitly decided it does not apply — either way, mark it done so the queue actually drains.',
|
|
744
|
+
inputSchema: {
|
|
745
|
+
type: 'object',
|
|
746
|
+
properties: {
|
|
747
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
748
|
+
feedback_ids: { type: 'array', items: { type: 'string' }, description: 'The `id` field of each feedback entry to mark processed (from read_graph_feedback\'s output).' }
|
|
749
|
+
},
|
|
750
|
+
required: ['devmind_path', 'feedback_ids']
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: 'link_nodes',
|
|
755
|
+
description: 'Adds a missing edge between two EXISTING nodes — the correction for a report like "get_node_code showed 0 used_by but X really calls Y". Evidence-gated: the evidence file must still exist and (if you give a snippet) must still contain it, checked fresh at call time — a stale claim is refused, not trusted. Prefer record_alias instead when the root cause is a generated/aliased binding (e.g. a framework hook) referencing many callers at once — one alias fixes them all, this fixes one edge.',
|
|
756
|
+
inputSchema: {
|
|
757
|
+
type: 'object',
|
|
758
|
+
properties: {
|
|
759
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
760
|
+
from_node_id: { type: 'string', description: 'The caller/source node id' },
|
|
761
|
+
to_node_id: { type: 'string', description: 'The callee/target node id' },
|
|
762
|
+
evidence_file: { type: 'string', description: 'Absolute path to the file proving this edge is real — checked to still exist.' },
|
|
763
|
+
evidence_snippet: { type: 'string', description: 'A short excerpt from evidence_file proving the reference — checked to still be present in the file\'s CURRENT content.' }
|
|
764
|
+
},
|
|
765
|
+
required: ['devmind_path', 'from_node_id', 'to_node_id', 'evidence_file']
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
name: 'record_alias',
|
|
770
|
+
description: 'Attaches an alias to a node — one implementation, another exported handle it is ALSO referenced by (a generated hook, a renamed default-export import, a re-export). This is the preferred fix over link_nodes when the report is about a generated/aliased binding: ONE alias makes every caller of that name resolve correctly, not just the one you happened to find. Evidence-gated the same way as link_nodes. Additive only — merges with any aliases already on the node (from a deterministic detector, e.g. the RTK Query one, or an earlier correction), never overwrites them.',
|
|
771
|
+
inputSchema: {
|
|
772
|
+
type: 'object',
|
|
773
|
+
properties: {
|
|
774
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
775
|
+
node_id: { type: 'string', description: 'The node this alias belongs to' },
|
|
776
|
+
alias: { type: 'string', description: 'The alternate name callers actually reference (e.g. a generated hook name)' },
|
|
777
|
+
evidence_file: { type: 'string', description: 'Absolute path to the file proving this alias is real — checked to still exist.' },
|
|
778
|
+
evidence_snippet: { type: 'string', description: 'A short excerpt proving the alias name is actually referenced there — checked to still be present in the file\'s CURRENT content.' }
|
|
779
|
+
},
|
|
780
|
+
required: ['devmind_path', 'node_id', 'alias', 'evidence_file']
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
name: 'merge_nodes',
|
|
785
|
+
description: 'Merges from_node_id into into_node_id — for when a graph-problem report (or your own review) shows two node candidates were never really distinct entities. Reassigns all connections and history from from_node_id onto into_node_id, folds from_node_id\'s aliases (and its own name) into into_node_id\'s alias set, then deprecates from_node_id — nothing is hard-deleted, so this stays reversible.',
|
|
786
|
+
inputSchema: {
|
|
787
|
+
type: 'object',
|
|
788
|
+
properties: {
|
|
789
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
790
|
+
from_node_id: { type: 'string', description: 'The node to merge away' },
|
|
791
|
+
into_node_id: { type: 'string', description: 'The node it should be merged into (this one survives)' }
|
|
792
|
+
},
|
|
793
|
+
required: ['devmind_path', 'from_node_id', 'into_node_id']
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: 'split_node',
|
|
798
|
+
description: 'Splits an over-coarse node into several new ones — for when one node was extracted too broadly (e.g. a whole class where each method deserved its own node). Each name in new_symbols must be a REAL, separately-locatable declaration already in the ORIGINAL node\'s file (re-extracted deterministically via the AST, same as the indexer\'s own gap-fill path) — this carves out symbols that already exist, it never fabricates new code. A name that cannot be located is reported in `failed`, not silently dropped; the original node is only deprecated once at least one split actually succeeds.',
|
|
799
|
+
inputSchema: {
|
|
800
|
+
type: 'object',
|
|
801
|
+
properties: {
|
|
802
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
803
|
+
node_id: { type: 'string', description: 'The over-coarse node to split' },
|
|
804
|
+
new_symbols: { type: 'array', items: { type: 'string' }, description: 'Exact symbol names to carve out of the node\'s file (e.g. method names of a class node)' }
|
|
805
|
+
},
|
|
806
|
+
required: ['devmind_path', 'node_id', 'new_symbols']
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
name: 'create_missing_node',
|
|
811
|
+
description: 'Deterministically creates a node from a real declaration the indexer never extracted — for when a graph-problem report points at a symbol that genuinely exists in the code but has no node. Reuses the same AST-derived, no-LLM extraction the indexer\'s own gap-fill path uses; refuses (no node created) if the symbol cannot actually be located in the given file.',
|
|
812
|
+
inputSchema: {
|
|
813
|
+
type: 'object',
|
|
814
|
+
properties: {
|
|
815
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
816
|
+
file_path: { type: 'string', description: 'Absolute path to the file containing the symbol' },
|
|
817
|
+
symbol_name: { type: 'string', description: 'The exact symbol name (or Class.method) to locate and create a node for' }
|
|
818
|
+
},
|
|
819
|
+
required: ['devmind_path', 'file_path', 'symbol_name']
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
name: 'flag_indexer_rule',
|
|
824
|
+
description: 'Records a candidate for a PERMANENT deterministic indexer rule (like the RTK Query hook detector) — for when several graph-problem reports turn out to be the same recurring pattern (e.g. "every report was this framework\'s generated-binding convention"). This does not change the graph itself; it is a note for a human to review and, if it recurs often enough, turn into a real detector so the same class of miss stops needing hand-fixes forever. Use evidence_count to reflect how many feedback entries this pattern explains — that is the priority signal for whoever reviews the log.',
|
|
825
|
+
inputSchema: {
|
|
826
|
+
type: 'object',
|
|
827
|
+
properties: {
|
|
828
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
829
|
+
pattern: { type: 'string', description: 'Short description of the recurring pattern' },
|
|
830
|
+
evidence_count: { type: 'number', description: 'How many feedback entries this pattern was inferred from' },
|
|
831
|
+
examples: { type: 'array', items: { type: 'string' }, description: 'A few representative examples (e.g. "file.ts:42"), not every occurrence' }
|
|
832
|
+
},
|
|
833
|
+
required: ['devmind_path', 'pattern', 'evidence_count', 'examples']
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
name: 'get_activity_log',
|
|
838
|
+
description: 'The one tool for "what changed" — replaces get_recent_changes/get_developer_activity/get_changes_by_requirement (all three removed; this covers everything they did, plus what none of them did: the actual FILES touched). One entry per commit_changes call, filterable by developer, a time window, one session, and/or requirement/ticket text — all filters compose (AND together). Each entry reports `files` (every file that commit touched — this is what "show me all the files you changed" needs, e.g. before writing tests against recent work), `node_ids`, `developer`/`created_at`/`request`/`summary`/`status`, and `source`. The response also includes `all_files` (every distinct file across the returned entries, flattened) and `total_matched` (how many matched BEFORE `limit` — compare with `total_messages` to detect truncation). TWO STORES: the local activity log is rich but gitignored, so it is empty on a teammate\'s clone or your second machine; committed graph history is shared by everyone but lossier. `source` picks between them and DEFAULTS TO AUTO — local first, shared history only if local has nothing, so a fresh clone still gets an answer. Use source:"both" for a team-wide view (your own local entries plus every session that did not run on this machine — no double-counting). Graph-backed responses carry a `caveats` array; read it before acting on them.',
|
|
839
|
+
inputSchema: {
|
|
840
|
+
type: 'object',
|
|
841
|
+
properties: {
|
|
842
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
843
|
+
source: {
|
|
844
|
+
type: 'string',
|
|
845
|
+
enum: ['auto', 'local', 'graph', 'both'],
|
|
846
|
+
description: 'Which store to read. "auto" (default): local activity log, falling back to shared graph history only when local returns nothing — the right choice on a fresh clone. "both": local PLUS shared history for every session that did not happen on this machine — the only way to see TEAMMATES\' work when you also have local activity of your own, since auto stops at the first non-empty store. "local": this machine only (full fidelity — verbatim request text, revert status, whole-file edits). "graph": committed history only (shared, but status is always "applied", `request` degrades to the reasoning\'s Requirement field, and untraced whole-file edits are absent).'
|
|
742
847
|
},
|
|
743
|
-
|
|
744
|
-
|
|
848
|
+
developer: { type: 'string', description: 'Case-insensitive substring match on developer name/email (e.g. "AbialiDr"). Omit for all developers. On graph-backed entries this reads the Developer field recorded in the commit reasoning, and is null for commits made before a DEVELOPER_NAME was configured.' },
|
|
849
|
+
session_id: { type: 'string', description: 'Restrict to one session id (from start_session).' },
|
|
850
|
+
since_hours: { type: 'number', description: 'Lookback window in hours — e.g. 48 for "the past 2 days". Ignored if `since` is also given.' },
|
|
851
|
+
since: { type: 'string', description: 'ISO timestamp lower bound (inclusive). Takes priority over since_hours.' },
|
|
852
|
+
until: { type: 'string', description: 'ISO timestamp upper bound (inclusive).' },
|
|
853
|
+
requirement_contains: { type: 'string', description: 'Case-insensitive substring match against the request text or summary — for finding changes tied to a ticket/requirement.' },
|
|
854
|
+
limit: { type: 'number', description: 'Maximum entries to return, most recent first (optional, default 100). Check `total_matched` to see how many were dropped.' }
|
|
855
|
+
},
|
|
856
|
+
required: ['devmind_path']
|
|
745
857
|
}
|
|
746
|
-
|
|
858
|
+
},
|
|
859
|
+
// NOTE: `search_decisions` is intentionally NOT listed here anymore. `search_nodes`'s BM25
|
|
860
|
+
// layer indexes reasoning text from EVERY history revision now (not just the latest — see
|
|
861
|
+
// rebuildSearchIndex), so a decision from any revision is findable there. The handler below
|
|
862
|
+
// is retained so any direct/legacy call still works.
|
|
863
|
+
// NOTE: `search_code` is intentionally NOT listed here anymore. `search_nodes` now
|
|
864
|
+
// falls back to the same code-content search automatically when the identifier
|
|
865
|
+
// match is empty, so there is no longer a reason to advertise two search tools.
|
|
866
|
+
// The handler below is retained so any direct/legacy call still works.
|
|
867
|
+
// NOTE: `get_orphaned_nodes` is intentionally NOT listed here anymore. `analyze_graph`
|
|
868
|
+
// already computes and reports the identical data (`orphaned_nodes`) as one section of
|
|
869
|
+
// its broader health check — this tool had no capability `analyze_graph` didn't already
|
|
870
|
+
// have. The handler below is retained so any direct/legacy call still works.
|
|
871
|
+
{
|
|
872
|
+
name: 'get_visualizer_url',
|
|
873
|
+
description: 'Get the local URL for the interactive code-graph view — one page with Chat and Graph tabs (the Graph tab has its own 2D/3D toggle). Same thing `devsmind view` opens.',
|
|
874
|
+
inputSchema: {
|
|
875
|
+
type: 'object',
|
|
876
|
+
properties: {
|
|
877
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
878
|
+
},
|
|
879
|
+
required: ['devmind_path']
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
name: 'analyze_graph',
|
|
884
|
+
description: 'Run a local, zero-token health check on the graph: god entities (high fan-in/out), circular dependency cycles, orphaned nodes, dangling edges, duplicate/case-collision ids, history missing developer attribution, empty code snapshots, spurious/built-in nodes, missing files, git-detected renames, and git-tracked code files with zero graph nodes. Purely local SQLite/filesystem/git queries — no LLM calls. Call this periodically (or when the graph feels stale/wrong) instead of guessing why context looks off. Set fix:true to auto-apply only the SAFE fixes (soft-deprecate dead nodes, remove dangling edges, migrate detected renames) — everything else is report-only and needs a human or agent decision.',
|
|
885
|
+
inputSchema: {
|
|
886
|
+
type: 'object',
|
|
887
|
+
properties: {
|
|
888
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
889
|
+
fix: { type: 'boolean', description: 'If true, applies safe automatic fixes (default: false — dry run/report only)' },
|
|
890
|
+
god_entity_threshold: { type: 'number', description: 'Connection-degree threshold to flag a god entity (default: 15)' }
|
|
891
|
+
},
|
|
892
|
+
required: ['devmind_path']
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
// NOTE: `workflow_pause`, `workflow_resume`, `workflow_get_steps`, `workflow_search`,
|
|
896
|
+
// `workflow_add_artifact`, `workflow_read_artifact` and `workflow_sync_retroactive` are
|
|
897
|
+
// intentionally NOT listed here anymore. pause/resume became `workflow_bind` — the pointer
|
|
898
|
+
// they moved was project-wide AND synced through git, so one session (or one teammate)
|
|
899
|
+
// silently took a workflow from another mid-work. get_steps folded into
|
|
900
|
+
// `workflow_get_context`, which is paged now. search folded into `workflow_list`'s `query`,
|
|
901
|
+
// which finally matches name/description — the old one scanned step summaries only, so
|
|
902
|
+
// looking a workflow up by its own name returned nothing. The artifact pair became
|
|
903
|
+
// `workflow_add_step`'s `doc_paths` plus the file paths `workflow_get_context` returns.
|
|
904
|
+
// sync_retroactive became `workflow_sync`, which reads the activity log instead of being
|
|
905
|
+
// handed a list the agent assembled itself. pause/resume keep working as bind aliases
|
|
906
|
+
// below; the rest are gone.
|
|
907
|
+
{
|
|
908
|
+
name: 'workflow_create',
|
|
909
|
+
description: 'Start a named thread for a piece of functionality you will build over more than one session (e.g. "Wallet Integration"). A workflow is a BACKWARD-LOOKING log of how that functionality grew — read later to understand how the code got this way. It is not a task list. Creating one does NOT bind you to it: call workflow_bind next if you want this session\'s commits recorded on it.',
|
|
910
|
+
inputSchema: {
|
|
911
|
+
type: 'object',
|
|
912
|
+
properties: {
|
|
913
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
914
|
+
name: { type: 'string', description: 'Short human-readable name for the feature/workflow' },
|
|
915
|
+
description: { type: 'string', description: 'Brief description of the goal — this is what a later session matches against to decide whether new work belongs here' }
|
|
916
|
+
},
|
|
917
|
+
required: ['devmind_path', 'name', 'description']
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
name: 'workflow_bind',
|
|
922
|
+
description: 'Attach THIS session to a workflow, so every commit_changes from here automatically adds a step to it. Pass no workflow_id to detach. Binding is local to your session and is never shared: it does not move, pause, or steal anyone else\'s workflow, and two sessions can work different workflows — or the same one — at once without interfering. Replaces workflow_pause/workflow_resume, which moved one project-wide pointer that every session and every teammate shared.',
|
|
923
|
+
inputSchema: {
|
|
924
|
+
type: 'object',
|
|
925
|
+
properties: {
|
|
926
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
927
|
+
workflow_id: { type: 'string', description: 'The workflow to work on. OMIT THIS to unbind — later commits then attach to nothing, which workflow_sync can fix afterwards.' }
|
|
928
|
+
},
|
|
929
|
+
required: ['devmind_path']
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
name: 'workflow_list',
|
|
934
|
+
description: 'List workflows, newest-touched first. Call this when starting work that MIGHT belong to an existing multi-session feature — if a description matches, ask the user whether to continue it (workflow_bind) rather than starting fresh and losing its history. Pass `query` to match on name AND description. Archived workflows are hidden unless include_archived is set. `total` is the true count before the page, and `bound_workflow_id` tells you what this session is already on.',
|
|
935
|
+
inputSchema: {
|
|
936
|
+
type: 'object',
|
|
937
|
+
properties: {
|
|
938
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
939
|
+
query: { type: 'string', description: 'Optional: match this text against workflow name and description' },
|
|
940
|
+
include_archived: { type: 'boolean', description: 'Include archived (retired) workflows (default: false)' },
|
|
941
|
+
limit: { type: 'number', description: 'Max workflows in this page (default 25, max 200)' },
|
|
942
|
+
offset: { type: 'number', description: 'Skip this many before the returned page (default 0)' }
|
|
943
|
+
},
|
|
944
|
+
required: ['devmind_path']
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
name: 'workflow_get_context',
|
|
949
|
+
description: 'Read a workflow\'s story: its steps in order, each with the reasoning behind it and the node ids it touched, plus any docs attached. This is what you call after binding, to understand how the feature reached its current shape — the decisions in sequence, including ones that produced no code. PAGED: `steps_total` is exact; a `truncated` flag plus a `hint` name the next call. Use `last_n` to read the most recent steps, which is usually what catching up means. Docs come back as file paths — read the file yourself if you need its contents.',
|
|
950
|
+
inputSchema: {
|
|
951
|
+
type: 'object',
|
|
952
|
+
properties: {
|
|
953
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
954
|
+
workflow_id: { type: 'string', description: 'The workflow to read' },
|
|
955
|
+
last_n: { type: 'number', description: 'Read only the most recent N steps (max 500). Best way to catch up on a long thread — takes precedence over limit/offset.' },
|
|
956
|
+
limit: { type: 'number', description: 'Max steps in this page when paging forward (default 50, max 500)' },
|
|
957
|
+
offset: { type: 'number', description: 'Skip this many steps before the returned page (default 0)' }
|
|
958
|
+
},
|
|
959
|
+
required: ['devmind_path', 'workflow_id']
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
name: 'workflow_add_step',
|
|
964
|
+
description: 'Record ONE step on a workflow, with the docs behind it, in the same call. You do NOT need this for ordinary code work — commit_changes already adds a step automatically whenever the session is bound. Call it for the thing a commit cannot express: A DECISION OR RESEARCH FINDING THAT CHANGED NO CODE (e.g. "evaluated Razorpay, no split settlements, going with Stripe"). That is the one kind of knowledge nothing else in DevsMind keeps — git has the diff and history has the per-node reasoning, but neither records what was considered and rejected. Attach the docs it came from via doc_paths.',
|
|
965
|
+
inputSchema: {
|
|
966
|
+
type: 'object',
|
|
967
|
+
properties: {
|
|
968
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
969
|
+
workflow_id: { type: 'string', description: 'Workflow to add this step to (optional — defaults to whatever this session is bound to)' },
|
|
970
|
+
summary: { type: 'string', description: 'One line: what was decided or found' },
|
|
971
|
+
reasoning: { type: 'string', description: 'The why behind it — what was considered, what was rejected, and on what grounds. This is the part nobody can reconstruct later from the code that survived.' },
|
|
972
|
+
node_ids: { type: 'array', items: { type: 'string' }, description: 'Optional node ids this step relates to. Leave empty for a pure research/decision step.' },
|
|
973
|
+
doc_paths: { type: 'array', items: { type: 'string' }, description: 'Optional paths to research/spec docs behind this step, relative to the repo. Stored as PATHS, never copies, so they stay current and are already shared with your team — a path outside the configured repos is rejected, since it would not exist for anyone else.' }
|
|
974
|
+
},
|
|
975
|
+
required: ['devmind_path', 'summary']
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
name: 'workflow_sync',
|
|
980
|
+
description: 'Attach work you already did onto a workflow, after the fact — for when you were unbound, or bound to the wrong thread. Reads your LOCAL activity log (this machine only) and proposes one step per request you worked on. DRY RUN BY DEFAULT: the first call writes nothing and returns what it would attach, so show that to the user and call again with confirm:true. Safe to re-run — the edits behind each created step are marked consumed, so nothing is ever attached twice.',
|
|
981
|
+
inputSchema: {
|
|
982
|
+
type: 'object',
|
|
983
|
+
properties: {
|
|
984
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
985
|
+
workflow_id: { type: 'string', description: 'The workflow to attach the work to' },
|
|
986
|
+
confirm: { type: 'boolean', description: 'Must be true to actually write. Leave unset to preview first (recommended — a wrong sync edits a shared, committed record).' },
|
|
987
|
+
message_ids: { type: 'array', items: { type: 'string' }, description: 'Optional: attach only these messages (ids from a previous dry run) instead of everything in scope' },
|
|
988
|
+
since_hours: { type: 'number', description: 'Optional: only consider work from the last N hours' },
|
|
989
|
+
all_sessions: { type: 'boolean', description: 'Look across all local sessions rather than just this one (default false) — use when attaching work from an earlier day.' }
|
|
990
|
+
},
|
|
991
|
+
required: ['devmind_path', 'workflow_id']
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
name: 'workflow_archive',
|
|
996
|
+
description: 'Hide a workflow from the default list, or bring it back with archived:false. Deliberately not called "complete" — a feature is never finished, it just stops being worked on, and the old completed/paused status was a lifecycle nobody maintained. Archiving is reversible and keeps every step intact.',
|
|
997
|
+
inputSchema: {
|
|
998
|
+
type: 'object',
|
|
999
|
+
properties: {
|
|
1000
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
1001
|
+
workflow_id: { type: 'string', description: 'The workflow to archive' },
|
|
1002
|
+
archived: { type: 'boolean', description: 'false to unarchive (default: true)' }
|
|
1003
|
+
},
|
|
1004
|
+
required: ['devmind_path', 'workflow_id']
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
name: 'workflow_import',
|
|
1009
|
+
description: 'Import existing flow/architecture docs (markdown describing a feature) as workflows, so material a team already wrote lives where you already look. Re-importing the same file updates its workflow in place rather than duplicating it.',
|
|
1010
|
+
inputSchema: {
|
|
1011
|
+
type: 'object',
|
|
1012
|
+
properties: {
|
|
1013
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
1014
|
+
folder_path: { type: 'string', description: 'Folder to import every .md file from (one workflow per file)' },
|
|
1015
|
+
file_path: { type: 'string', description: 'A single .md file to import instead of a folder' }
|
|
1016
|
+
},
|
|
1017
|
+
required: ['devmind_path']
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
];
|
|
1021
|
+
// Every tool except start_session (the one tool that CREATES a session) requires
|
|
1022
|
+
// session_id — injected here once instead of duplicated on all ~35 schemas above.
|
|
1023
|
+
const SESSION_ID_PROP = {
|
|
1024
|
+
type: 'string',
|
|
1025
|
+
description: 'The DevsMind session token for THIS conversation, from start_session. Required. Pass the exact value start_session returned on every call; never invent one.'
|
|
747
1026
|
};
|
|
1027
|
+
for (const t of tools) {
|
|
1028
|
+
if (t.name === 'start_session')
|
|
1029
|
+
continue;
|
|
1030
|
+
// Read-only tools don't require a session (see SESSION_EXEMPT_READ_TOOLS) — don't inject it.
|
|
1031
|
+
if (SESSION_EXEMPT_READ_TOOLS.has(t.name))
|
|
1032
|
+
continue;
|
|
1033
|
+
const schema = t.inputSchema;
|
|
1034
|
+
if (schema.properties && !schema.properties.session_id)
|
|
1035
|
+
schema.properties.session_id = SESSION_ID_PROP;
|
|
1036
|
+
if (Array.isArray(schema.required) && !schema.required.includes('session_id'))
|
|
1037
|
+
schema.required.push('session_id');
|
|
1038
|
+
}
|
|
1039
|
+
// Bound (stateful) server: the process already knows its one brain, so drop `devmind_path`
|
|
1040
|
+
// from every advertised schema. The AI never has to discover, remember, or re-send a path —
|
|
1041
|
+
// the single biggest source of per-call noise and cross-project mistakes. `resolveDevmindPath`
|
|
1042
|
+
// ignores the arg anyway when bound, so a stray one from an old client still works; this just
|
|
1043
|
+
// stops us ASKING for it. Unbound (tests/legacy) leaves the schemas exactly as they were.
|
|
1044
|
+
if (boundDevmindPath) {
|
|
1045
|
+
for (const t of tools) {
|
|
1046
|
+
const schema = t.inputSchema;
|
|
1047
|
+
if (schema.properties?.devmind_path)
|
|
1048
|
+
delete schema.properties.devmind_path;
|
|
1049
|
+
if (Array.isArray(schema.required)) {
|
|
1050
|
+
schema.required = schema.required.filter((r) => r !== 'devmind_path');
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return { tools };
|
|
748
1055
|
});
|
|
749
1056
|
// ── Tool Execution ───────────────────────────────────────────────────────────
|
|
750
1057
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
@@ -752,55 +1059,262 @@ function createMcpServer() {
|
|
|
752
1059
|
if (!args) {
|
|
753
1060
|
throw new Error('Arguments are required');
|
|
754
1061
|
}
|
|
755
|
-
|
|
1062
|
+
// start_session mints the id; every WRITE call must carry it back. This is the sole minting
|
|
1063
|
+
// point — no auto-mint fallback — and the resolved id is echoed on every response below so it
|
|
1064
|
+
// survives context compaction. Read-only tools (SESSION_EXEMPT_READ_TOOLS) run without one:
|
|
1065
|
+
// sessionId stays '' for them, which is falsy so the trailing echo is skipped, and no write
|
|
1066
|
+
// handler is exempt so none ever sees the empty value.
|
|
1067
|
+
let sessionId;
|
|
1068
|
+
if (name === 'start_session') {
|
|
1069
|
+
sessionId = crypto.randomUUID();
|
|
1070
|
+
}
|
|
1071
|
+
else if (args.session_id) {
|
|
1072
|
+
sessionId = String(args.session_id);
|
|
1073
|
+
}
|
|
1074
|
+
else if (SESSION_EXEMPT_READ_TOOLS.has(name)) {
|
|
1075
|
+
sessionId = '';
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
return {
|
|
1079
|
+
isError: true,
|
|
1080
|
+
content: [{
|
|
1081
|
+
type: 'text',
|
|
1082
|
+
text: JSON.stringify({
|
|
1083
|
+
error: 'session_id is required. Call start_session once at the start of this conversation, then pass the session_id it returns on every DevsMind write call, including this one. Never invent a session_id.'
|
|
1084
|
+
})
|
|
1085
|
+
}]
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
const run = async () => {
|
|
756
1089
|
switch (name) {
|
|
757
|
-
case 'get_node_summary': {
|
|
758
|
-
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
759
|
-
const nodeId = requireStr(args, 'node_id', 'get_node_summary');
|
|
760
|
-
const db = getDatabase(devmindPath);
|
|
761
|
-
const node = db.getNode(nodeId);
|
|
762
|
-
if (!node) {
|
|
763
|
-
return {
|
|
764
|
-
content: [
|
|
765
|
-
{ type: 'text', text: JSON.stringify({ exists: false, node_id: nodeId }) }
|
|
766
|
-
]
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
const connections = db.getConnections(nodeId);
|
|
770
|
-
const connectionCount = connections.uses.length + connections.usedBy.length;
|
|
771
|
-
const historyList = db.listHistory(nodeId);
|
|
772
|
-
const latestHistory = db.getLatestHistory(nodeId);
|
|
773
|
-
const summary = {
|
|
774
|
-
exists: true,
|
|
775
|
-
node_id: node.id,
|
|
776
|
-
name: node.name,
|
|
777
|
-
type: node.type,
|
|
778
|
-
file_path: node.file_path,
|
|
779
|
-
signature: node.signature,
|
|
780
|
-
connection_count: connectionCount,
|
|
781
|
-
history_count: historyList.length,
|
|
782
|
-
last_updated: latestHistory ? latestHistory.updated_at : node.created_at
|
|
783
|
-
};
|
|
784
|
-
return {
|
|
785
|
-
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }]
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
1090
|
case 'list_nodes': {
|
|
789
1091
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
790
1092
|
const type = args.type ? String(args.type) : undefined;
|
|
791
1093
|
const filePath = args.file_path ? String(args.file_path) : undefined;
|
|
792
1094
|
const includeDeprecated = args.include_deprecated === true;
|
|
1095
|
+
// Paged, because this used to return EVERY matching node with no bound of any kind — on a
|
|
1096
|
+
// real backend that was ~600KB in one response, which blew past the client's inline limit,
|
|
1097
|
+
// spilled to a file, and truncated on read from there too. An enumeration tool is exactly
|
|
1098
|
+
// the one that must be paged: the whole point of asking is that you don't know how many
|
|
1099
|
+
// there are.
|
|
1100
|
+
const limit = clampInt(args.limit, LIST_NODES_DEFAULT_LIMIT, 1, 500);
|
|
1101
|
+
const offset = clampInt(args.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
793
1102
|
const db = getDatabase(devmindPath);
|
|
794
|
-
const
|
|
1103
|
+
const filter = { type, file_path: filePath, include_deprecated: includeDeprecated };
|
|
1104
|
+
const total = db.countNodes(filter);
|
|
1105
|
+
const nodes = db.listNodes({ ...filter, limit, offset });
|
|
1106
|
+
const payload = { nodes, total, offset };
|
|
1107
|
+
if (offset + nodes.length < total) {
|
|
1108
|
+
payload.truncated = true;
|
|
1109
|
+
payload.hint = `${total} nodes match; showing ${offset + 1}-${offset + nodes.length}. Pass offset:${offset + nodes.length} for the next page, or narrow with type/file_path.`;
|
|
1110
|
+
}
|
|
1111
|
+
// Backstop for a page that is still oversized because the nodes themselves are heavy
|
|
1112
|
+
// (long descriptions). Same threshold and the same "say what you dropped" contract as
|
|
1113
|
+
// search_nodes — a caller must never have to guess whether it got the whole field set.
|
|
1114
|
+
let text = JSON.stringify(payload);
|
|
1115
|
+
if (text.length > SEARCH_COMPACT_THRESHOLD) {
|
|
1116
|
+
payload.nodes = nodes.map(n => ({ id: n.id, name: n.name, type: n.type, file_path: n.file_path }));
|
|
1117
|
+
payload.compacted = LIST_NODES_COMPACT_NOTE;
|
|
1118
|
+
text = JSON.stringify(payload);
|
|
1119
|
+
}
|
|
795
1120
|
return {
|
|
796
|
-
content: [{ type: 'text', text
|
|
1121
|
+
content: [{ type: 'text', text }]
|
|
797
1122
|
};
|
|
798
1123
|
}
|
|
799
1124
|
case 'get_node_code': {
|
|
800
1125
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
801
1126
|
const nodeId = requireStr(args, 'node_id', 'get_node_code');
|
|
802
1127
|
const db = getDatabase(devmindPath);
|
|
803
|
-
const
|
|
1128
|
+
const live = db.getLiveCode(nodeId);
|
|
1129
|
+
// Internally getLiveCode() also reports source/snapshot_outdated (used by revertability()
|
|
1130
|
+
// below) — deliberately not forwarded here. Surfacing "cached"/"outdated" to the AI just
|
|
1131
|
+
// made it distrust code that was in fact current, for no actionable benefit.
|
|
1132
|
+
let result;
|
|
1133
|
+
if (live.exists) {
|
|
1134
|
+
const RECENT_HISTORY_LIMIT = 3;
|
|
1135
|
+
const NEIGHBOR_SIGNATURE_CAP = 120;
|
|
1136
|
+
const FILE_OUTLINE_CAP = 40;
|
|
1137
|
+
// Always-on node metadata — `description` especially: it's the highest-signal field
|
|
1138
|
+
// there is (a human-written summary of purpose), and it was already stored, already
|
|
1139
|
+
// fetched-adjacent, and simply never returned before.
|
|
1140
|
+
const node = db.getNode(live.node_id);
|
|
1141
|
+
// ES `import` lines only — does NOT capture require()/dynamic import()/`export…from`
|
|
1142
|
+
// re-exports (see listFileImports' own doc comment). Attached unconditionally: this
|
|
1143
|
+
// is the direct fix for the #1 recurring complaint, that a bare function body doesn't
|
|
1144
|
+
// say what its identifiers resolve to, so every AI ended up re-opening the whole file
|
|
1145
|
+
// just to read its import lines.
|
|
1146
|
+
const imports = live.file_path ? (0, ast_1.listFileImports)(live.file_path) : [];
|
|
1147
|
+
const connCounts = db.getConnectionCounts([live.node_id]).get(live.node_id) ?? { uses: 0, usedBy: 0 };
|
|
1148
|
+
const historyCount = db.getHistoryCounts([live.node_id]).get(live.node_id) ?? 0;
|
|
1149
|
+
result = {
|
|
1150
|
+
exists: true,
|
|
1151
|
+
node_id: live.node_id,
|
|
1152
|
+
file_path: live.file_path,
|
|
1153
|
+
code: live.code,
|
|
1154
|
+
updated_at: live.updated_at
|
|
1155
|
+
};
|
|
1156
|
+
if (node) {
|
|
1157
|
+
result.name = node.name;
|
|
1158
|
+
result.type = node.type;
|
|
1159
|
+
if (node.signature)
|
|
1160
|
+
result.signature = node.signature;
|
|
1161
|
+
if (node.description)
|
|
1162
|
+
result.description = node.description;
|
|
1163
|
+
if (node.aliases && node.aliases.length > 0)
|
|
1164
|
+
result.aliases = node.aliases;
|
|
1165
|
+
if (node.deprecated)
|
|
1166
|
+
result.deprecated = true;
|
|
1167
|
+
}
|
|
1168
|
+
result.imports = imports;
|
|
1169
|
+
result.uses = connCounts.uses;
|
|
1170
|
+
result.used_by = connCounts.usedBy;
|
|
1171
|
+
if (connCounts.usedBy === 0)
|
|
1172
|
+
result.used_by_note = database_1.NO_STATIC_CALLERS_NOTE;
|
|
1173
|
+
// Named direct neighbors — always on unless explicitly zeroed. This is what turns
|
|
1174
|
+
// `used_by: 213` into actual leads, and is exactly what closes the depth-1
|
|
1175
|
+
// turn-stacking complaint: the common "who calls this" question no longer needs a
|
|
1176
|
+
// second tool call at all. Deterministically ordered (file_path, name) — see
|
|
1177
|
+
// getConnections' own doc comment — so a capped slice on a hub node is a stable,
|
|
1178
|
+
// meaningful page, not a lottery over unordered SQL rows.
|
|
1179
|
+
const neighborsLimit = clampInt(args.neighbors_limit, 20, 0, 200);
|
|
1180
|
+
const neighborsOffset = clampInt(args.neighbors_offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1181
|
+
if (neighborsLimit > 0) {
|
|
1182
|
+
const conn = db.getConnections(live.node_id, { limit: neighborsLimit, offset: neighborsOffset });
|
|
1183
|
+
const toSummary = (n) => ({
|
|
1184
|
+
node_id: n.id,
|
|
1185
|
+
name: n.name,
|
|
1186
|
+
type: n.type,
|
|
1187
|
+
file_path: n.file_path,
|
|
1188
|
+
signature: n.signature ? n.signature.slice(0, NEIGHBOR_SIGNATURE_CAP) : n.signature
|
|
1189
|
+
});
|
|
1190
|
+
result.uses_nodes = conn.uses.map(toSummary);
|
|
1191
|
+
result.used_by_nodes = conn.usedBy.map(toSummary);
|
|
1192
|
+
// Counts stay exact (`uses`/`used_by` above); these arrays are a possibly-capped
|
|
1193
|
+
// PAGE of them — never let the array length be mistaken for the count.
|
|
1194
|
+
if (neighborsOffset + conn.uses.length < connCounts.uses) {
|
|
1195
|
+
result.uses_truncated = true;
|
|
1196
|
+
result.uses_hint = `${connCounts.uses} callees, ${neighborsOffset + conn.uses.length} shown — pass neighbors_offset:${neighborsOffset + conn.uses.length} for the next page, or graph_depth:2 + graph_direction:"out" for the transitive call flow.`;
|
|
1197
|
+
}
|
|
1198
|
+
if (neighborsOffset + conn.usedBy.length < connCounts.usedBy) {
|
|
1199
|
+
result.used_by_truncated = true;
|
|
1200
|
+
result.used_by_hint = `${connCounts.usedBy} callers, ${neighborsOffset + conn.usedBy.length} shown — pass neighbors_offset:${neighborsOffset + conn.usedBy.length} for the next page, or graph_depth:2 + graph_direction:"in" for the transitive blast radius.`;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// Everything ELSE declared in this file — the "was this renamed out from under me" /
|
|
1204
|
+
// "what's nearby" answer a raw file read used to be the only way to get. Omitted (not
|
|
1205
|
+
// an empty array) for a non-AST-parseable file, so that reads as "cannot tell", never
|
|
1206
|
+
// as "this file is empty".
|
|
1207
|
+
const includeOutline = args.file_outline !== false;
|
|
1208
|
+
if (includeOutline && live.file_path) {
|
|
1209
|
+
if (!(0, ast_1.isAstParseable)(live.file_path)) {
|
|
1210
|
+
result.file_outline_unavailable = 'not an AST-parseable file';
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
const allEntries = (0, ast_1.outlineFile)(live.file_path);
|
|
1214
|
+
const ownSymbol = (0, ast_1.parseNodeId)(live.node_id)?.symbolName;
|
|
1215
|
+
const anchor = ownSymbol ? allEntries.find(e => e.qualified === ownSymbol) : undefined;
|
|
1216
|
+
const others = ownSymbol ? allEntries.filter(e => e.qualified !== ownSymbol) : allEntries;
|
|
1217
|
+
const fileNodes = db.getNodesByFilePath(live.file_path);
|
|
1218
|
+
const nodeIdBySymbol = new Map();
|
|
1219
|
+
for (const n of fileNodes) {
|
|
1220
|
+
const parsed = (0, ast_1.parseNodeId)(n.id);
|
|
1221
|
+
if (parsed)
|
|
1222
|
+
nodeIdBySymbol.set(parsed.symbolName, n.id);
|
|
1223
|
+
}
|
|
1224
|
+
let page = others;
|
|
1225
|
+
const truncated = others.length > FILE_OUTLINE_CAP;
|
|
1226
|
+
if (truncated) {
|
|
1227
|
+
// Nearest the target node by line distance, then re-sorted back into file
|
|
1228
|
+
// order — what's relevant in a 3000-line file is what shares a section with
|
|
1229
|
+
// the node just returned, not whatever happens to sit at the top.
|
|
1230
|
+
const anchorLine = anchor?.start_line ?? 0;
|
|
1231
|
+
page = [...others]
|
|
1232
|
+
.sort((a, b) => Math.abs(a.start_line - anchorLine) - Math.abs(b.start_line - anchorLine))
|
|
1233
|
+
.slice(0, FILE_OUTLINE_CAP)
|
|
1234
|
+
.sort((a, b) => a.start_line - b.start_line);
|
|
1235
|
+
}
|
|
1236
|
+
result.file_outline = page.map(e => {
|
|
1237
|
+
const entryNodeId = nodeIdBySymbol.get(e.qualified);
|
|
1238
|
+
return {
|
|
1239
|
+
name: e.name,
|
|
1240
|
+
qualified: e.qualified,
|
|
1241
|
+
type: e.type,
|
|
1242
|
+
start_line: e.start_line,
|
|
1243
|
+
end_line: e.end_line,
|
|
1244
|
+
exported: e.exported,
|
|
1245
|
+
...(entryNodeId ? { node_id: entryNodeId } : {})
|
|
1246
|
+
};
|
|
1247
|
+
});
|
|
1248
|
+
result.file_outline_total = others.length;
|
|
1249
|
+
if (truncated)
|
|
1250
|
+
result.file_outline_truncated = true;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// recent_history's shape/limit is UNCHANGED regardless of `history` — always the last
|
|
1254
|
+
// RECENT_HISTORY_LIMIT reasoning-only summaries, never code — so every existing
|
|
1255
|
+
// consumer of this field keeps working unchanged. `history:"full"` ADDS `full_history`
|
|
1256
|
+
// alongside it; it never replaces it.
|
|
1257
|
+
const historyMode = args.history === 'none' || args.history === 'full' ? args.history : 'recent';
|
|
1258
|
+
result.recent_history = historyMode === 'none'
|
|
1259
|
+
? []
|
|
1260
|
+
: (historyCount > 0 ? db.getRecentHistorySummaries(live.node_id, RECENT_HISTORY_LIMIT) : []);
|
|
1261
|
+
result.history_count = historyCount;
|
|
1262
|
+
if (historyMode === 'recent' && historyCount > RECENT_HISTORY_LIMIT) {
|
|
1263
|
+
result.history_hint = `${historyCount} revisions recorded, ${RECENT_HISTORY_LIMIT} shown (reasoning only). Pass history:"full" in this same call for the code snapshots and diffable edits.`;
|
|
1264
|
+
}
|
|
1265
|
+
if (historyMode === 'full') {
|
|
1266
|
+
const limit = clampInt(args.history_limit, 5, 0, 25);
|
|
1267
|
+
const offset = clampInt(args.history_offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
1268
|
+
const page = db.getHistoryPage(live.node_id, limit, offset);
|
|
1269
|
+
result.full_history = page.entries;
|
|
1270
|
+
if (offset + page.entries.length < page.total) {
|
|
1271
|
+
result.history_truncated = true;
|
|
1272
|
+
result.history_hint = `${page.total} revisions total, showing ${offset + 1}-${offset + page.entries.length}. Pass history_offset:${offset + page.entries.length} in this same call for the next page.`;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
// Transitive graph — off by default (depth 0). The direct neighbors above already
|
|
1276
|
+
// answer the common case; this is the explicit "go further" escape hatch, same BFS
|
|
1277
|
+
// the old get_node_graph tool ran, just embedded in the same call instead of a
|
|
1278
|
+
// separate one.
|
|
1279
|
+
const graphDepth = clampInt(args.graph_depth, 0, 0, 10);
|
|
1280
|
+
if (graphDepth >= 1) {
|
|
1281
|
+
const graphDirection = args.graph_direction === 'out' || args.graph_direction === 'in' || args.graph_direction === 'both'
|
|
1282
|
+
? args.graph_direction
|
|
1283
|
+
: 'both';
|
|
1284
|
+
// Clamped, not `Number(...)`: an unparseable budget used to become NaN, and since
|
|
1285
|
+
// every `spent + len > NaN` is false, that silently meant an UNLIMITED budget —
|
|
1286
|
+
// the exact opposite of what passing a budget asks for. `0` is now a real value
|
|
1287
|
+
// (root code only), distinct from omitting the param.
|
|
1288
|
+
const graphCodeBudget = clampInt(args.graph_code_budget, GRAPH_CODE_BUDGET_DEFAULT, 0, 200_000);
|
|
1289
|
+
const graph = db.getGraph(live.node_id, graphDepth, {
|
|
1290
|
+
direction: graphDirection,
|
|
1291
|
+
includeCode: args.graph_code === true,
|
|
1292
|
+
codeCharBudget: graphCodeBudget,
|
|
1293
|
+
maxNodes: GRAPH_MAX_NODES
|
|
1294
|
+
});
|
|
1295
|
+
// The root's code is already on `result.code` above, read live from disk. getGraph
|
|
1296
|
+
// attaches it a second time (its root is budget-exempt), so leaving it would spend
|
|
1297
|
+
// a chunk of the budget on a byte-identical duplicate — on a 3KB root inside a 24KB
|
|
1298
|
+
// budget that's 12% of it, buying nothing.
|
|
1299
|
+
if (graph.nodes.length > 0 && graph.nodes[0].id === live.node_id) {
|
|
1300
|
+
delete graph.nodes[0].code;
|
|
1301
|
+
delete graph.nodes[0].code_source;
|
|
1302
|
+
}
|
|
1303
|
+
result.graph = graph;
|
|
1304
|
+
// Name what didn't fit, so "the rest" is reachable. Deliberately NOT a positional
|
|
1305
|
+
// cursor: the BFS is re-derived per call and the cut-off also depends on file
|
|
1306
|
+
// contents, so an index would silently skip or repeat nodes after any edit. An id
|
|
1307
|
+
// survives both, and is directly usable as the next call's argument.
|
|
1308
|
+
if (graph.code_omitted_node_ids?.length) {
|
|
1309
|
+
result.graph_code_hint =
|
|
1310
|
+
`${graph.code_omitted_node_ids.length} node(s) had their code dropped to stay inside graph_code_budget (${graphCodeBudget} chars). ` +
|
|
1311
|
+
`They are listed by id in graph.code_omitted_node_ids — call get_node_code on those directly, or re-issue with a larger graph_code_budget.`;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
result = { exists: false, node_id: live.node_id, message: live.message };
|
|
1317
|
+
}
|
|
804
1318
|
return {
|
|
805
1319
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
806
1320
|
};
|
|
@@ -830,13 +1344,12 @@ function createMcpServer() {
|
|
|
830
1344
|
node_id: requireStr(args, 'node_id', 'update_history'),
|
|
831
1345
|
file_path: filePath,
|
|
832
1346
|
code_snapshot: requireStr(args, 'code_snapshot', 'update_history'),
|
|
833
|
-
reasoning: args.reasoning,
|
|
834
1347
|
name: args.name ? String(args.name) : undefined,
|
|
835
1348
|
type: args.type ? String(args.type) : undefined,
|
|
836
1349
|
signature: args.signature ? String(args.signature) : undefined,
|
|
837
|
-
session_id:
|
|
1350
|
+
session_id: sessionId
|
|
838
1351
|
};
|
|
839
|
-
const summary = (0, staging_1.commitStagedChanges)(db, devmindPath, [entry]);
|
|
1352
|
+
const summary = await (0, staging_1.commitStagedChanges)(db, devmindPath, [entry], args.reasoning);
|
|
840
1353
|
const nodeId = entry.node_id.includes('#') ? entry.node_id : `${db.toRepoRelativePath(filePath)}#${entry.node_id}`;
|
|
841
1354
|
return {
|
|
842
1355
|
content: [
|
|
@@ -856,43 +1369,42 @@ function createMcpServer() {
|
|
|
856
1369
|
// ————————————————————————————————— Indexing tool handlers —————————————————————————————————
|
|
857
1370
|
case 'index_start': {
|
|
858
1371
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1372
|
+
const db = getDatabase(devmindPath);
|
|
859
1373
|
const { repos, total_files } = (0, scanner_1.scanRepoFiles)(devmindPath);
|
|
1374
|
+
const allFiles = repos.flatMap(r => r.files);
|
|
1375
|
+
// Informational only — files_total below stays the FULL INDEXABLE_EXTENSIONS scan
|
|
1376
|
+
// count, matching walkDir/scanRepoFiles, so resume arithmetic never changes shape
|
|
1377
|
+
// depending on how many of those files happen to be AST-parseable.
|
|
1378
|
+
const skippedByExt = {};
|
|
1379
|
+
for (const f of allFiles) {
|
|
1380
|
+
if (!(0, ast_1.isAstParseable)(f)) {
|
|
1381
|
+
const ext = path.extname(f).toLowerCase() || '(no extension)';
|
|
1382
|
+
skippedByExt[ext] = (skippedByExt[ext] || 0) + 1;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
const existingNodeCount = db.listNodes().length;
|
|
860
1386
|
const pad = (0, indexer_1.createScratchpad)(devmindPath, total_files);
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}));
|
|
1387
|
+
const batch = (0, index_build_1.extractFilesIntoGraph)(db, allFiles);
|
|
1388
|
+
pad.files_done = batch.filesExtracted.length;
|
|
1389
|
+
pad.last_file_indexed = batch.cursor;
|
|
1390
|
+
pad.nodes_created = batch.nodesCreated;
|
|
1391
|
+
(0, indexer_1.writeScratchpad)(devmindPath, pad);
|
|
867
1392
|
return {
|
|
868
1393
|
content: [{
|
|
869
1394
|
type: 'text',
|
|
870
1395
|
text: JSON.stringify({
|
|
871
|
-
message: 'Indexing
|
|
1396
|
+
message: 'Indexing started — structure is parsed locally and deterministically, no LLM. You never extract entities and never send code back: describe every node in `batch.nodes` with ONE add_description call, then call index_continue for the next batch.',
|
|
872
1397
|
scratchpad: pad,
|
|
873
|
-
repos:
|
|
1398
|
+
repos: repos.map(r => ({ repo_name: r.repo_name, repo_path: r.repo_path, file_count: r.file_count })),
|
|
874
1399
|
total_files,
|
|
1400
|
+
skipped: { count: Object.values(skippedByExt).reduce((a, b) => a + b, 0), by_extension: skippedByExt },
|
|
1401
|
+
existing_nodes: existingNodeCount > 0 ? existingNodeCount : undefined,
|
|
1402
|
+
batch: { files: batch.filesExtracted, nodes: batch.nodes, file_imports: batch.fileImports },
|
|
875
1403
|
instructions: [
|
|
876
|
-
'
|
|
877
|
-
'
|
|
878
|
-
'
|
|
879
|
-
'
|
|
880
|
-
'ONCE YOU START INDEXING, DO NOT STOP or pause to ask for confirmation between checkpoints. Keep executing and indexing files continuously until the codebase is fully indexed or your context token limit is reached.',
|
|
881
|
-
'IF YOU ENCOUNTER CONTEXT RESETS, RESUME WORK BY CALLING "index_continue" AND CONTINUOUSLY COMMIT PROGRESS BY CALLING "index_checkpoint" EVERY 10 FILES.',
|
|
882
|
-
'',
|
|
883
|
-
'📋 CODE EXCLUSION & PRECISION RULES:',
|
|
884
|
-
'1. EXCLUDE Language Globals / Built-ins: Do NOT stage nodes for Promise, Map, Set, JSON, console, Error, Object, Array, RegExp, Date, Math, etc.',
|
|
885
|
-
'2. EXCLUDE Primitive/Native Types: Do NOT stage nodes for string, number, boolean, any, void, unknown, never, null, undefined, dict, list, etc.',
|
|
886
|
-
'3. EXCLUDE External / Third-party Modules: Do NOT stage nodes for lodash, express, react, @nestjs/common, etc.',
|
|
887
|
-
'4. INTERNAL ENTITIES ONLY: Only stage nodes for constructs defined inside this codebase.',
|
|
888
|
-
'',
|
|
889
|
-
'📋 STAGE → COMMIT INDEXING PROTOCOL:',
|
|
890
|
-
'1. For each file in each repo: read it, extract ALL defined nodes — functions, methods, classes, interfaces, types, DTOs, routing handlers, schemas, resolvers, etc.',
|
|
891
|
-
'2. Call stage_change for EVERY entity found — pass its node_id, file_path, code_snapshot, reasoning, and the most specific taxonomy type. You do NOT need to figure out connections; commit_changes resolves them from the code via AST.',
|
|
892
|
-
'3. Call index_checkpoint every 10 files to save progress.',
|
|
893
|
-
'4. Every ~50 entities (or at the end of a repo), call commit_changes to flush the staged buffer — it creates all nodes, writes all history, and resolves all connections (including into already-committed nodes) in one pass. Committing in batches keeps the buffer small.',
|
|
894
|
-
'5. When the whole codebase is staged and committed, call index_complete.',
|
|
895
|
-
'6. AFTER index_complete, CALL "recheck_graph" to automatically prune any spurious, built-in, or orphaned nodes and ensure high graph precision.'
|
|
1404
|
+
'NEVER write or execute external scripts (Python, Node.js, Bash, etc.) to index files — this tool already did the extraction; you only describe.',
|
|
1405
|
+
'Write a 1-3 sentence description for EVERY node in `batch.nodes` — PURPOSE, not a restatement of the name ("verifyCredentials verifies credentials" is rejected) — using words a developer would search by later. Send them all in ONE `add_description` call. Pass `type` too if the AST\'s generic type (function/method/class/…) is too coarse for what this actually is (e.g. "nest_service", "react_component").',
|
|
1406
|
+
'Then call index_continue for the next batch. Repeat until it reports no files remain — do not stop or pause for confirmation between batches.',
|
|
1407
|
+
'When index_continue reports extraction complete and every node is described, call index_complete, then recheck_graph to prune anything spurious.'
|
|
896
1408
|
]
|
|
897
1409
|
}, null, 2)
|
|
898
1410
|
}]
|
|
@@ -900,26 +1412,33 @@ function createMcpServer() {
|
|
|
900
1412
|
}
|
|
901
1413
|
case 'index_checkpoint': {
|
|
902
1414
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
903
|
-
const pad = (0, indexer_1.
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
: 0;
|
|
1415
|
+
const pad = (0, indexer_1.readScratchpad)(devmindPath);
|
|
1416
|
+
if (!pad) {
|
|
1417
|
+
return {
|
|
1418
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'No indexing session found. Call index_start first.' }) }]
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
const db = getDatabase(devmindPath);
|
|
1422
|
+
const activeNodes = db.getAllNodes().filter(n => !n.deprecated);
|
|
1423
|
+
const described = activeNodes.filter(n => n.description).length;
|
|
1424
|
+
const pct = pad.files_total > 0 ? Math.round((pad.files_done / pad.files_total) * 100) : 0;
|
|
914
1425
|
return {
|
|
915
1426
|
content: [{
|
|
916
1427
|
type: 'text',
|
|
917
|
-
text: JSON.stringify({
|
|
1428
|
+
text: JSON.stringify({
|
|
1429
|
+
scratchpad: pad,
|
|
1430
|
+
progress: `${pad.files_done}/${pad.files_total} files (${pct}%)`,
|
|
1431
|
+
phase: pad.phase,
|
|
1432
|
+
nodes_total: activeNodes.length,
|
|
1433
|
+
described,
|
|
1434
|
+
undescribed: activeNodes.length - described
|
|
1435
|
+
}, null, 2)
|
|
918
1436
|
}]
|
|
919
1437
|
};
|
|
920
1438
|
}
|
|
921
1439
|
case 'index_continue': {
|
|
922
1440
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1441
|
+
const db = getDatabase(devmindPath);
|
|
923
1442
|
const pad = (0, indexer_1.readScratchpad)(devmindPath);
|
|
924
1443
|
if (!pad) {
|
|
925
1444
|
return {
|
|
@@ -931,42 +1450,118 @@ function createMcpServer() {
|
|
|
931
1450
|
content: [{ type: 'text', text: JSON.stringify({ status: 'complete', message: 'Indexing already completed.', scratchpad: pad }, null, 2) }]
|
|
932
1451
|
};
|
|
933
1452
|
}
|
|
934
|
-
// Re-
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1453
|
+
// Re-served ahead of a fresh batch, every call, so a still-undescribed node from an
|
|
1454
|
+
// earlier batch grows the visible backlog instead of silently vanishing from view.
|
|
1455
|
+
const stillUndescribed = (0, index_build_1.pendingDescriptionNodes)(db, 25);
|
|
1456
|
+
// Repeated every call because a context reset means index_start's own instructions
|
|
1457
|
+
// are gone from view — without this, a resumed session silently stops describing.
|
|
1458
|
+
const baseInstructions = [
|
|
1459
|
+
'Describe every node in `still_undescribed` (from earlier batches) AND `batch.nodes` (if present, from this call) with ONE add_description call covering both.',
|
|
1460
|
+
'NEVER write or execute external scripts to index files — the server already extracted the structure; you only describe.'
|
|
1461
|
+
];
|
|
1462
|
+
if (pad.phase === 1) {
|
|
1463
|
+
const { repos } = (0, scanner_1.scanRepoFiles)(devmindPath);
|
|
1464
|
+
const allFiles = repos.flatMap(r => r.files);
|
|
1465
|
+
const startIdx = pad.last_file_indexed ? allFiles.findIndex(f => f === pad.last_file_indexed) + 1 : 0;
|
|
1466
|
+
const remainingFiles = allFiles.slice(startIdx);
|
|
1467
|
+
if (remainingFiles.length > 0) {
|
|
1468
|
+
const batch = (0, index_build_1.extractFilesIntoGraph)(db, remainingFiles);
|
|
1469
|
+
pad.files_done += batch.filesExtracted.length;
|
|
1470
|
+
pad.last_file_indexed = batch.cursor;
|
|
1471
|
+
pad.nodes_created += batch.nodesCreated;
|
|
1472
|
+
(0, indexer_1.writeScratchpad)(devmindPath, pad);
|
|
1473
|
+
return {
|
|
1474
|
+
content: [{
|
|
1475
|
+
type: 'text',
|
|
1476
|
+
text: JSON.stringify({
|
|
1477
|
+
message: 'Resuming extraction.',
|
|
1478
|
+
scratchpad: pad,
|
|
1479
|
+
still_undescribed: stillUndescribed,
|
|
1480
|
+
batch: { files: batch.filesExtracted, nodes: batch.nodes, file_imports: batch.fileImports },
|
|
1481
|
+
instructions: [...baseInstructions, 'Then call index_continue again. Repeat until it reports no files remain.']
|
|
1482
|
+
}, null, 2)
|
|
1483
|
+
}]
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
pad.phase = 2;
|
|
1487
|
+
(0, indexer_1.writeScratchpad)(devmindPath, pad);
|
|
1488
|
+
}
|
|
940
1489
|
return {
|
|
941
1490
|
content: [{
|
|
942
1491
|
type: 'text',
|
|
943
1492
|
text: JSON.stringify({
|
|
944
|
-
message: '
|
|
1493
|
+
message: 'All files extracted. Describe any remaining nodes, then call index_complete.',
|
|
945
1494
|
scratchpad: pad,
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1495
|
+
still_undescribed: stillUndescribed,
|
|
1496
|
+
instructions: stillUndescribed.length > 0
|
|
1497
|
+
? [...baseInstructions, 'Once every node is described, call index_complete.']
|
|
1498
|
+
: ['Every node is described — call index_complete.']
|
|
949
1499
|
}, null, 2)
|
|
950
1500
|
}]
|
|
951
1501
|
};
|
|
952
1502
|
}
|
|
953
1503
|
case 'index_complete': {
|
|
954
1504
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
955
|
-
const pad = (0, indexer_1.completeScratchpad)(devmindPath);
|
|
956
1505
|
const db = getDatabase(devmindPath);
|
|
1506
|
+
const pad = (0, indexer_1.readScratchpad)(devmindPath);
|
|
1507
|
+
if (!pad) {
|
|
1508
|
+
return {
|
|
1509
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'No indexing session found. Call index_start first.' }) }]
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
if (pad.status === 'complete') {
|
|
1513
|
+
return {
|
|
1514
|
+
content: [{ type: 'text', text: JSON.stringify({ status: 'complete', message: 'Indexing already completed.', scratchpad: pad }, null, 2) }]
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
const { total_files } = (0, scanner_1.scanRepoFiles)(devmindPath);
|
|
1518
|
+
if (pad.phase === 1 && pad.files_done < total_files) {
|
|
1519
|
+
return {
|
|
1520
|
+
isError: true,
|
|
1521
|
+
content: [{
|
|
1522
|
+
type: 'text',
|
|
1523
|
+
text: JSON.stringify({
|
|
1524
|
+
error: `${total_files - pad.files_done} file(s) still unextracted — call index_continue first.`,
|
|
1525
|
+
scratchpad: pad
|
|
1526
|
+
})
|
|
1527
|
+
}]
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
const edgeResult = (0, index_build_1.resolveEdgesIncrementally)(db, devmindPath, pad);
|
|
1531
|
+
if (!edgeResult.done) {
|
|
1532
|
+
return {
|
|
1533
|
+
content: [{
|
|
1534
|
+
type: 'text',
|
|
1535
|
+
text: JSON.stringify({
|
|
1536
|
+
complete: false,
|
|
1537
|
+
resume: true,
|
|
1538
|
+
message: `Connection resolution paused at ${edgeResult.nodesDone}/${edgeResult.nodesTotal} nodes — call index_complete again to continue.`,
|
|
1539
|
+
scratchpad: pad
|
|
1540
|
+
}, null, 2)
|
|
1541
|
+
}]
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
const finalPad = (0, indexer_1.completeScratchpad)(devmindPath);
|
|
957
1545
|
db.vacuum();
|
|
1546
|
+
const undescribedCount = db.getAllNodes().filter(n => !n.deprecated && !n.description).length;
|
|
958
1547
|
return {
|
|
959
1548
|
content: [{
|
|
960
1549
|
type: 'text',
|
|
961
1550
|
text: JSON.stringify({
|
|
962
1551
|
message: '✅ Indexing complete! Full graph is now available.',
|
|
963
1552
|
summary: {
|
|
964
|
-
files_indexed:
|
|
965
|
-
nodes_created:
|
|
966
|
-
connections_created:
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1553
|
+
files_indexed: finalPad.files_done,
|
|
1554
|
+
nodes_created: finalPad.nodes_created,
|
|
1555
|
+
connections_created: edgeResult.edgesAdded,
|
|
1556
|
+
missing_nodes_filled: edgeResult.missingFilled,
|
|
1557
|
+
started_at: finalPad.started_at,
|
|
1558
|
+
completed_at: finalPad.updated_at
|
|
1559
|
+
},
|
|
1560
|
+
undescribed_count: undescribedCount,
|
|
1561
|
+
hint: undescribedCount > 0
|
|
1562
|
+
? `${undescribedCount} node(s) still have no description — run "devsmind describe" to backfill them, or describe them now with add_description.`
|
|
1563
|
+
: undefined,
|
|
1564
|
+
next_step: 'recheck_graph — prunes any spurious, built-in, or orphaned nodes.'
|
|
970
1565
|
}, null, 2)
|
|
971
1566
|
}]
|
|
972
1567
|
};
|
|
@@ -987,18 +1582,6 @@ function createMcpServer() {
|
|
|
987
1582
|
}]
|
|
988
1583
|
};
|
|
989
1584
|
}
|
|
990
|
-
if (!args.reasoning) {
|
|
991
|
-
return {
|
|
992
|
-
isError: true,
|
|
993
|
-
content: [{
|
|
994
|
-
type: 'text',
|
|
995
|
-
text: JSON.stringify({
|
|
996
|
-
edited: false,
|
|
997
|
-
error: 'edit_node needs reasoning (what_changed, why, goal). It is recorded against whatever code this edit turns out to touch, and exists nowhere else once this turn ends.'
|
|
998
|
-
})
|
|
999
|
-
}]
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
1585
|
const rawPath = String(args.file_path);
|
|
1003
1586
|
const filePath = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(workspaceRoot, rawPath);
|
|
1004
1587
|
if (!editDb.isPathAllowed(filePath)) {
|
|
@@ -1051,19 +1634,42 @@ function createMcpServer() {
|
|
|
1051
1634
|
return { id: n.id, symbolName: parsed ? parsed.symbolName : (n.id.split('#').pop() || n.name) };
|
|
1052
1635
|
});
|
|
1053
1636
|
const touched = (0, ast_1.findTouchedSymbols)(filePath, result.ranges || [], knownHere, result.before);
|
|
1054
|
-
|
|
1637
|
+
// Only meaningful when exactly one symbol was touched — otherwise there is no way to
|
|
1638
|
+
// know which one it describes. The file write above already succeeded regardless of
|
|
1639
|
+
// what happens here: an invalid or inapplicable description never blocks the edit
|
|
1640
|
+
// itself, only whether commit_changes will later need a separate add_description call.
|
|
1641
|
+
let singleSymbolDescription;
|
|
1642
|
+
let descriptionNote;
|
|
1643
|
+
if (args.description !== undefined) {
|
|
1644
|
+
if (touched.length !== 1) {
|
|
1645
|
+
descriptionNote = touched.length === 0
|
|
1646
|
+
? 'description was ignored: this edit touched no function/class, so there was nothing to describe.'
|
|
1647
|
+
: `description was ignored: this edit touched ${touched.length} symbols, which one it describes is ambiguous — use add_description for each after this call.`;
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
const check = (0, tokenize_1.validateDescription)(String(args.description), touched[0].name);
|
|
1651
|
+
if (check.ok) {
|
|
1652
|
+
singleSymbolDescription = String(args.description);
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
descriptionNote = `description was rejected and ignored: ${check.error}`;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1055
1659
|
const staged = [];
|
|
1660
|
+
const diffBlocks = [];
|
|
1056
1661
|
for (const t of touched) {
|
|
1057
1662
|
const nodeId = t.node_id || `${editDb.toRepoRelativePath(filePath)}#${t.symbolName}`;
|
|
1058
|
-
|
|
1663
|
+
(0, staging_1.stageEntry)(devmindPath, {
|
|
1059
1664
|
node_id: nodeId,
|
|
1060
1665
|
file_path: filePath,
|
|
1061
1666
|
code_snapshot: t.codeSnapshot,
|
|
1062
|
-
|
|
1667
|
+
code_before: t.codeBefore,
|
|
1063
1668
|
name: t.name,
|
|
1064
1669
|
type: t.type,
|
|
1065
1670
|
signature: t.signature || undefined,
|
|
1066
|
-
|
|
1671
|
+
description: singleSymbolDescription,
|
|
1672
|
+
session_id: sessionId
|
|
1067
1673
|
});
|
|
1068
1674
|
const conns = t.node_id ? editDb.getConnections(t.node_id) : { uses: [], usedBy: [] };
|
|
1069
1675
|
const priorHistory = (t.node_id ? editDb.getFullHistory(t.node_id) : [])
|
|
@@ -1076,135 +1682,473 @@ function createMcpServer() {
|
|
|
1076
1682
|
type: t.type,
|
|
1077
1683
|
lines: `${t.startLine}-${t.endLine}`,
|
|
1078
1684
|
is_new_to_graph: t.isNew,
|
|
1685
|
+
described: singleSymbolDescription !== undefined,
|
|
1079
1686
|
callers: conns.usedBy.slice(0, 10).map(n => ({ id: n.id, name: n.name, file_path: n.file_path })),
|
|
1080
1687
|
callers_total: conns.usedBy.length,
|
|
1081
1688
|
calls_out: conns.uses.slice(0, 10).map(n => ({ id: n.id, name: n.name })),
|
|
1082
1689
|
prior_history: priorHistory
|
|
1083
1690
|
});
|
|
1691
|
+
// Human-facing view of this same edit, so the change is visible in the session as a
|
|
1692
|
+
// fenced diff — kept separate from the JSON above so the text isn't shipped twice.
|
|
1693
|
+
// The +/- lines already show add-vs-modify; no "new" tag, which would read as "new
|
|
1694
|
+
// code" when it only ever meant "not yet in the graph".
|
|
1695
|
+
diffBlocks.push(`### ${t.name} \`${path.basename(filePath)}:${t.startLine}-${t.endLine}\`\n\`\`\`diff\n${(0, diff_1.renderUnifiedDiff)(t.codeBefore ?? '', t.codeSnapshot)}\n\`\`\``);
|
|
1696
|
+
}
|
|
1697
|
+
// Nothing traced into the code graph doesn't mean nothing worth keeping: a non-code file
|
|
1698
|
+
// (CSS, XML, JSON, ...) or an edit landing outside any function (an import line, a
|
|
1699
|
+
// top-level constant) still gets its whole-file before/after staged for the LOCAL
|
|
1700
|
+
// activity log, so it shows up and is individually revertable in `devsmind view` -> Chat
|
|
1701
|
+
// right alongside traced code edits — the graph stays code-only, the activity log doesn't.
|
|
1702
|
+
let fileEditStaged = false;
|
|
1703
|
+
if (touched.length === 0) {
|
|
1704
|
+
const afterContent = fs.readFileSync(filePath, 'utf-8');
|
|
1705
|
+
(0, staging_1.stageFileEdit)(devmindPath, {
|
|
1706
|
+
file_path: filePath,
|
|
1707
|
+
before: result.before ?? '',
|
|
1708
|
+
after: afterContent,
|
|
1709
|
+
session_id: sessionId
|
|
1710
|
+
});
|
|
1711
|
+
fileEditStaged = true;
|
|
1084
1712
|
}
|
|
1713
|
+
// Scoped to THIS session — the buffer is shared by every session pointed at this
|
|
1714
|
+
// .devmind directory, so counting the raw buffer length here would inflate the "pending"
|
|
1715
|
+
// total with other sessions' unrelated in-flight work and mislead this agent about how
|
|
1716
|
+
// much IT still needs to commit. See partitionStagedForSession.
|
|
1717
|
+
const { entries: myPendingEntries, fileEdits: myPendingFileEdits } = (0, staging_1.partitionStagedForSession)(devmindPath, sessionId);
|
|
1718
|
+
const pending = myPendingEntries.length + myPendingFileEdits.length;
|
|
1085
1719
|
const ext = path.extname(filePath).toLowerCase();
|
|
1086
1720
|
const callerCount = staged.reduce((sum, s) => sum + s.callers_total, 0);
|
|
1721
|
+
const newUndescribedCount = staged.filter(s => s.is_new_to_graph && !s.described).length;
|
|
1722
|
+
const newDescribedCount = staged.filter(s => s.is_new_to_graph && s.described).length;
|
|
1087
1723
|
const what = result.created ? 'Created the file and recorded' : 'Recorded';
|
|
1088
1724
|
let reminder;
|
|
1089
1725
|
if (staged.length) {
|
|
1090
1726
|
reminder = callerCount
|
|
1091
1727
|
? `${what} ${staged.length} node(s). ${callerCount} node(s) call what you changed — if you altered a signature or contract, check them before moving on. Nothing reaches the graph until commit_changes.`
|
|
1092
1728
|
: `${what} ${staged.length} node(s). Nothing reaches the graph until commit_changes.`;
|
|
1729
|
+
if (newUndescribedCount > 0) {
|
|
1730
|
+
reminder += ` ${newUndescribedCount} of these are NEW and undescribed — commit_changes will refuse the batch without a description for each (call add_description before you commit, while this code is still fresh in context, or pass description directly to edit_node next time when it's a single new symbol).`;
|
|
1731
|
+
}
|
|
1732
|
+
if (newDescribedCount > 0) {
|
|
1733
|
+
reminder += ` ${newDescribedCount} new node(s) already described via this call — no add_description round trip needed for those.`;
|
|
1734
|
+
}
|
|
1735
|
+
if (descriptionNote) {
|
|
1736
|
+
reminder += ` ${descriptionNote}`;
|
|
1737
|
+
}
|
|
1093
1738
|
}
|
|
1094
1739
|
else if (!scanner_1.INDEXABLE_EXTENSIONS.has(ext)) {
|
|
1095
|
-
reminder = `${ext || 'This file type'} is intentionally out of scope for the graph —
|
|
1740
|
+
reminder = `${ext || 'This file type'} is intentionally out of scope for the code graph — no node was recorded. The whole-file change is staged for the local activity log though, so commit_changes will still make it revertable there.`;
|
|
1096
1741
|
}
|
|
1097
1742
|
else if ((0, ast_1.isAstParseable)(filePath)) {
|
|
1098
1743
|
reminder = result.created
|
|
1099
|
-
? 'The file was created, but it declares no function or class, so
|
|
1100
|
-
: 'This edit did not land inside any function or class (an import, a top-level constant, or similar), so
|
|
1744
|
+
? 'The file was created, but it declares no function or class, so no graph node was recorded. The whole-file change is staged for the local activity log, so commit_changes will still make it revertable there.'
|
|
1745
|
+
: 'This edit did not land inside any function or class (an import, a top-level constant, or similar), so no graph node was recorded. The whole-file change is staged for the local activity log, so commit_changes will still make it revertable there.';
|
|
1101
1746
|
}
|
|
1102
1747
|
else {
|
|
1103
|
-
reminder = `${ext}
|
|
1748
|
+
reminder = `${ext} has no parser support in DevsMind yet (TS/JS only, for now) — this could not be traced into the graph. The whole-file change is staged for the local activity log though, so commit_changes will still make it revertable there.`;
|
|
1104
1749
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1750
|
+
// Two blocks on purpose: a rendered diff for the human watching the session (clients
|
|
1751
|
+
// that highlight markdown colour the ```diff fence; the rest show plain +/- lines), and
|
|
1752
|
+
// the JSON the agent parses. The diff is not embedded in the JSON, so it ships once.
|
|
1753
|
+
const content = [];
|
|
1754
|
+
if (diffBlocks.length) {
|
|
1755
|
+
content.push({ type: 'text', text: `${result.created ? 'Created' : 'Edited'} ${path.basename(filePath)}\n\n${diffBlocks.join('\n\n')}` });
|
|
1756
|
+
}
|
|
1757
|
+
content.push({
|
|
1758
|
+
type: 'text',
|
|
1759
|
+
text: JSON.stringify({
|
|
1760
|
+
edited: true,
|
|
1761
|
+
created: !!result.created,
|
|
1762
|
+
file_path: filePath,
|
|
1763
|
+
replacements: result.replacements,
|
|
1764
|
+
recorded: staged.length,
|
|
1765
|
+
pending_count: pending,
|
|
1766
|
+
touched: staged,
|
|
1767
|
+
file_edit_staged: fileEditStaged,
|
|
1768
|
+
reminder
|
|
1769
|
+
}, null, 2)
|
|
1770
|
+
});
|
|
1771
|
+
return { content };
|
|
1120
1772
|
}
|
|
1121
|
-
case '
|
|
1773
|
+
case 'add_description': {
|
|
1122
1774
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1123
|
-
const
|
|
1124
|
-
const
|
|
1125
|
-
if (
|
|
1775
|
+
const db = getDatabase(devmindPath);
|
|
1776
|
+
const rawList = Array.isArray(args.descriptions) ? args.descriptions : [];
|
|
1777
|
+
if (rawList.length === 0) {
|
|
1126
1778
|
return {
|
|
1127
1779
|
isError: true,
|
|
1128
|
-
content: [{
|
|
1129
|
-
type: 'text',
|
|
1130
|
-
text: JSON.stringify({
|
|
1131
|
-
staged: false,
|
|
1132
|
-
error: `'${ext || '(no extension)'}' is not a supported node file type — nothing was staged.`,
|
|
1133
|
-
reason: 'DevsMind models functions, classes, and logic entities in source code. Stylesheets (.css/.scss/.less), markup, JSON/config, docs, and other non-code assets are intentionally out of scope, not oversights — staging them would only bloat the graph with nodes that have no callers/callees to resolve. Do not retry this file.',
|
|
1134
|
-
supported_extensions: Array.from(scanner_1.INDEXABLE_EXTENSIONS).sort()
|
|
1135
|
-
})
|
|
1136
|
-
}]
|
|
1780
|
+
content: [{ type: 'text', text: JSON.stringify({ described: false, error: 'add_description needs a non-empty descriptions array.' }) }]
|
|
1137
1781
|
};
|
|
1138
1782
|
}
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1783
|
+
const staged = (0, staging_1.readStaged)(devmindPath);
|
|
1784
|
+
const results = [];
|
|
1785
|
+
for (const item of rawList) {
|
|
1786
|
+
const nodeId = item && item.node_id ? String(item.node_id) : '';
|
|
1787
|
+
const description = item && item.description ? String(item.description) : '';
|
|
1788
|
+
if (!nodeId) {
|
|
1789
|
+
results.push({ node_id: nodeId, ok: false, error: 'missing node_id', target: 'unknown' });
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
const check = (0, tokenize_1.validateDescription)(description, nodeId);
|
|
1793
|
+
if (!check.ok) {
|
|
1794
|
+
results.push({ node_id: nodeId, ok: false, error: check.error, target: 'unknown' });
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
// A staged-but-not-yet-committed entry takes priority: this is the normal path after
|
|
1798
|
+
// a commit_changes rejection, and the description must land in the buffer so it
|
|
1799
|
+
// reaches upsertNode when the AI retries the commit — writing straight to the DB here
|
|
1800
|
+
// would do nothing, since that node doesn't exist there yet.
|
|
1801
|
+
//
|
|
1802
|
+
// Restricted to entries THIS session owns (see belongsToSession in staging.ts) — the
|
|
1803
|
+
// buffer is shared by every session pointed at this .devmind directory, so without
|
|
1804
|
+
// this check one session could write a description onto another session's still
|
|
1805
|
+
// uncommitted node, which then ships with that commit under the OTHER session's
|
|
1806
|
+
// reasoning even though the describing session never touched or reviewed that change.
|
|
1807
|
+
const matchesNodeId = (e) => e.node_id === nodeId || `${db.toRepoRelativePath(e.file_path)}#${e.node_id}` === nodeId;
|
|
1808
|
+
const stagedEntry = staged.find(e => matchesNodeId(e) && (!e.session_id || e.session_id === sessionId));
|
|
1809
|
+
if (stagedEntry) {
|
|
1810
|
+
stagedEntry.description = description;
|
|
1811
|
+
if (item && item.type)
|
|
1812
|
+
stagedEntry.type = String(item.type);
|
|
1813
|
+
results.push({ node_id: nodeId, ok: true, target: 'staged' });
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
if (staged.some(matchesNodeId)) {
|
|
1817
|
+
results.push({
|
|
1818
|
+
node_id: nodeId, ok: false,
|
|
1819
|
+
error: 'staged by another session, not this one — that session must add the description (or commit first, then use add_description on the committed node).',
|
|
1820
|
+
target: 'unknown'
|
|
1821
|
+
});
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
// Otherwise this is a backfill/refresh on an already-committed node — write directly,
|
|
1825
|
+
// no commit needed. upsertNode's COALESCE means every other field is left untouched.
|
|
1826
|
+
const existing = db.getNode(nodeId);
|
|
1827
|
+
if (!existing) {
|
|
1828
|
+
results.push({ node_id: nodeId, ok: false, error: 'no such node — not staged, and not in the committed graph', target: 'unknown' });
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
db.upsertNode({
|
|
1832
|
+
id: existing.id,
|
|
1833
|
+
type: (item && item.type) ? String(item.type) : existing.type,
|
|
1834
|
+
name: existing.name,
|
|
1835
|
+
file_path: existing.file_path,
|
|
1836
|
+
signature: existing.signature,
|
|
1837
|
+
description
|
|
1838
|
+
});
|
|
1839
|
+
// Embed only on the COMMITTED path — a staged entry above mutates a JSON buffer with
|
|
1840
|
+
// nowhere to put a vector yet; those nodes get embedded once commit_changes lands
|
|
1841
|
+
// them in the DB via commitStagedChanges. No-op (returns null) if the optional ONNX
|
|
1842
|
+
// dependency is unavailable — the node just falls into the `devsmind embed` queue.
|
|
1843
|
+
const vector = await (0, embedder_1.embedTextInt8)(description);
|
|
1844
|
+
if (vector)
|
|
1845
|
+
db.upsertNodeVector(existing.id, vector, (0, embedder_1.hashDescription)(description));
|
|
1846
|
+
results.push({ node_id: nodeId, ok: true, target: 'committed' });
|
|
1847
|
+
}
|
|
1848
|
+
// Persist any staged-entry description writes back to the buffer.
|
|
1849
|
+
if (staged.some(e => e.description !== undefined)) {
|
|
1850
|
+
(0, staging_1.overwriteStaged)(devmindPath, staged);
|
|
1851
|
+
}
|
|
1852
|
+
const failed = results.filter(r => !r.ok);
|
|
1853
|
+
return {
|
|
1854
|
+
content: [{
|
|
1855
|
+
type: 'text',
|
|
1856
|
+
text: JSON.stringify({
|
|
1857
|
+
described: failed.length === 0,
|
|
1858
|
+
results,
|
|
1859
|
+
reminder: failed.length === 0
|
|
1860
|
+
? 'If these were for a commit_changes rejection, call commit_changes again now.'
|
|
1861
|
+
: `${failed.length} description(s) rejected — fix and retry just those.`
|
|
1862
|
+
}, null, 2)
|
|
1863
|
+
}]
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
case 'add_feedback': {
|
|
1867
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1868
|
+
const graphProblem = args.graph_problem;
|
|
1869
|
+
const edgeProblem = args.edge_problem;
|
|
1870
|
+
const toolsUsed = args.tools_used !== undefined ? String(args.tools_used) : undefined;
|
|
1871
|
+
const droppedAndWhy = args.dropped_and_why !== undefined ? String(args.dropped_and_why) : undefined;
|
|
1872
|
+
const devsmindBetter = args.devsmind_better !== undefined ? String(args.devsmind_better) : undefined;
|
|
1873
|
+
if (!graphProblem && !edgeProblem && toolsUsed === undefined && droppedAndWhy === undefined && devsmindBetter === undefined) {
|
|
1143
1874
|
return {
|
|
1144
1875
|
isError: true,
|
|
1145
|
-
content: [{
|
|
1146
|
-
type: 'text',
|
|
1147
|
-
text: JSON.stringify({
|
|
1148
|
-
staged: false,
|
|
1149
|
-
error: `file_path resolves outside the project's configured repos — nothing was staged.`,
|
|
1150
|
-
reason: 'stage_change only accepts paths inside a repo this project knows about, to prevent staging/reading files outside the project.',
|
|
1151
|
-
resolved_path: filePath
|
|
1152
|
-
})
|
|
1153
|
-
}]
|
|
1876
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'add_feedback needs at least one of: graph_problem, edge_problem, tools_used, dropped_and_why, devsmind_better.' }) }]
|
|
1154
1877
|
};
|
|
1155
1878
|
}
|
|
1156
|
-
|
|
1879
|
+
// Both graph/edge shapes are handled identically apart from the category label, so one
|
|
1880
|
+
// helper does both instead of duplicating the evidence-verification + append logic.
|
|
1881
|
+
const recordGraphSide = (category, entry) => {
|
|
1882
|
+
const text = entry.text ? String(entry.text) : '';
|
|
1883
|
+
if (!text.trim())
|
|
1884
|
+
return { ok: false, error: `${category}.text is required` };
|
|
1885
|
+
const nodeId = entry.node_id ? String(entry.node_id) : undefined;
|
|
1886
|
+
let evidence;
|
|
1887
|
+
if (entry.evidence) {
|
|
1888
|
+
const file = entry.evidence.file ? String(entry.evidence.file) : '';
|
|
1889
|
+
if (!file)
|
|
1890
|
+
return { ok: false, error: `${category}.evidence.file is required when evidence is given` };
|
|
1891
|
+
const snippet = entry.evidence.snippet ? String(entry.evidence.snippet) : undefined;
|
|
1892
|
+
// Verified fresh at call time, same evidence-gating as link_nodes/record_alias — a
|
|
1893
|
+
// stale or fabricated claim is refused outright, not silently trusted or downgraded.
|
|
1894
|
+
const verified = verifyEvidence(file, snippet);
|
|
1895
|
+
if (!verified.ok)
|
|
1896
|
+
return { ok: false, error: `${category} evidence verification failed — ${verified.reason}` };
|
|
1897
|
+
const line = typeof entry.evidence.line === 'number' ? entry.evidence.line : undefined;
|
|
1898
|
+
evidence = { file, line, snippet };
|
|
1899
|
+
}
|
|
1900
|
+
(0, feedback_1.appendGraphFeedback)(devmindPath, { session_id: sessionId, category, text, node_id: nodeId, evidence });
|
|
1901
|
+
return { ok: true };
|
|
1902
|
+
};
|
|
1903
|
+
const recorded = [];
|
|
1904
|
+
const rejected = [];
|
|
1905
|
+
if (graphProblem) {
|
|
1906
|
+
const r = recordGraphSide('graph_problem', graphProblem);
|
|
1907
|
+
if (r.ok)
|
|
1908
|
+
recorded.push('graph_problem');
|
|
1909
|
+
else
|
|
1910
|
+
rejected.push({ field: 'graph_problem', error: r.error });
|
|
1911
|
+
}
|
|
1912
|
+
if (edgeProblem) {
|
|
1913
|
+
const r = recordGraphSide('edge_problem', edgeProblem);
|
|
1914
|
+
if (r.ok)
|
|
1915
|
+
recorded.push('edge_problem');
|
|
1916
|
+
else
|
|
1917
|
+
rejected.push({ field: 'edge_problem', error: r.error });
|
|
1918
|
+
}
|
|
1919
|
+
if (toolsUsed !== undefined) {
|
|
1920
|
+
(0, feedback_1.appendProductFeedback)(devmindPath, { session_id: sessionId, category: 'tools_used', text: toolsUsed });
|
|
1921
|
+
recorded.push('tools_used');
|
|
1922
|
+
}
|
|
1923
|
+
if (droppedAndWhy !== undefined) {
|
|
1924
|
+
(0, feedback_1.appendProductFeedback)(devmindPath, { session_id: sessionId, category: 'dropped_and_why', text: droppedAndWhy });
|
|
1925
|
+
recorded.push('dropped_and_why');
|
|
1926
|
+
}
|
|
1927
|
+
if (devsmindBetter !== undefined) {
|
|
1928
|
+
(0, feedback_1.appendProductFeedback)(devmindPath, { session_id: sessionId, category: 'devsmind_better', text: devsmindBetter });
|
|
1929
|
+
recorded.push('devsmind_better');
|
|
1930
|
+
}
|
|
1931
|
+
// A rejected evidence claim fails the WHOLE call (nothing partially recorded from THAT
|
|
1932
|
+
// field) rather than silently downgrading it — same "refused, not trusted" stance as
|
|
1933
|
+
// link_nodes/record_alias. Fields that verified fine are still recorded; only the ones
|
|
1934
|
+
// that failed are reported back for a retry.
|
|
1935
|
+
if (rejected.length > 0) {
|
|
1157
1936
|
return {
|
|
1158
1937
|
isError: true,
|
|
1159
|
-
content: [{ type: 'text', text: JSON.stringify({
|
|
1938
|
+
content: [{ type: 'text', text: JSON.stringify({ recorded, rejected }, null, 2) }]
|
|
1160
1939
|
};
|
|
1161
1940
|
}
|
|
1162
|
-
const entry = {
|
|
1163
|
-
node_id: requireStr(args, 'node_id', 'stage_change'),
|
|
1164
|
-
file_path: filePath,
|
|
1165
|
-
code_snapshot: requireStr(args, 'code_snapshot', 'stage_change'),
|
|
1166
|
-
reasoning: args.reasoning,
|
|
1167
|
-
name: args.name ? String(args.name) : undefined,
|
|
1168
|
-
type: args.type ? String(args.type) : undefined,
|
|
1169
|
-
signature: args.signature ? String(args.signature) : undefined,
|
|
1170
|
-
session_id: args.session_id ? String(args.session_id) : undefined
|
|
1171
|
-
};
|
|
1172
|
-
const pendingCount = (0, staging_1.stageEntry)(devmindPath, entry);
|
|
1173
1941
|
return {
|
|
1174
|
-
content: [{
|
|
1175
|
-
type: 'text',
|
|
1176
|
-
text: JSON.stringify({
|
|
1177
|
-
staged: true,
|
|
1178
|
-
node_id: entry.node_id,
|
|
1179
|
-
pending_count: pendingCount,
|
|
1180
|
-
reminder: 'Call commit_changes once you have staged every touched file, or nothing is written to the graph.'
|
|
1181
|
-
})
|
|
1182
|
-
}]
|
|
1942
|
+
content: [{ type: 'text', text: JSON.stringify({ recorded }, null, 2) }]
|
|
1183
1943
|
};
|
|
1184
1944
|
}
|
|
1185
1945
|
case 'commit_changes': {
|
|
1946
|
+
const requestText = requireStr(args, 'message', 'commit_changes');
|
|
1186
1947
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1187
1948
|
const db = getDatabase(devmindPath);
|
|
1188
|
-
|
|
1189
|
-
|
|
1949
|
+
// Scoped to THIS session's own staged work — the buffer is shared by every session
|
|
1950
|
+
// pointed at this .devmind directory, so a plain commit must never sweep up another
|
|
1951
|
+
// session's still-in-progress edits (possibly from an unrelated file or repo) just
|
|
1952
|
+
// because they happened to be pending at the same time. See partitionStagedForSession.
|
|
1953
|
+
const { entries, fileEdits, otherSessionsPending } = (0, staging_1.partitionStagedForSession)(devmindPath, sessionId);
|
|
1954
|
+
if (entries.length === 0 && fileEdits.length === 0) {
|
|
1955
|
+
return {
|
|
1956
|
+
content: [{
|
|
1957
|
+
type: 'text',
|
|
1958
|
+
text: JSON.stringify({
|
|
1959
|
+
committed: false,
|
|
1960
|
+
message: otherSessionsPending > 0
|
|
1961
|
+
? `Nothing staged by this session. ${otherSessionsPending} entr(y/ies) from another session are pending but left untouched — call edit_node first.`
|
|
1962
|
+
: 'Nothing staged. Call edit_node first.'
|
|
1963
|
+
})
|
|
1964
|
+
}]
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
if (!args.reasoning) {
|
|
1968
|
+
return {
|
|
1969
|
+
isError: true,
|
|
1970
|
+
content: [{
|
|
1971
|
+
type: 'text',
|
|
1972
|
+
text: JSON.stringify({
|
|
1973
|
+
committed: false,
|
|
1974
|
+
error: 'commit_changes needs reasoning (what_changed, why, goal). It is recorded against EVERY node staged since the last commit, and exists nowhere else once this turn ends.'
|
|
1975
|
+
})
|
|
1976
|
+
}]
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
// Compulsory but never a real obstacle: every field just needs to be a string, and
|
|
1980
|
+
// "none" is a fully legitimate, expected value — this only rejects a call that omitted
|
|
1981
|
+
// the param entirely or answered with the wrong shape, never one that reported nothing
|
|
1982
|
+
// wrong. See the tool description for why "none" everywhere is fine.
|
|
1983
|
+
const feedback = args.feedback;
|
|
1984
|
+
const feedbackFields = ['graph_problems', 'edge_problems', 'tools_used', 'dropped_and_why', 'devsmind_better'];
|
|
1985
|
+
const feedbackValid = !!feedback && typeof feedback === 'object'
|
|
1986
|
+
&& feedbackFields.every(f => typeof feedback[f] === 'string');
|
|
1987
|
+
if (!feedbackValid) {
|
|
1988
|
+
return {
|
|
1989
|
+
isError: true,
|
|
1990
|
+
content: [{
|
|
1991
|
+
type: 'text',
|
|
1992
|
+
text: JSON.stringify({
|
|
1993
|
+
committed: false,
|
|
1994
|
+
error: 'commit_changes needs feedback (graph_problems, edge_problems, tools_used, dropped_and_why, devsmind_better), each a string. This is how DevsMind improves over time — every field must be ANSWERED but none needs to contain a problem: pass "none" wherever nothing applies. Do not invent an issue just to fill a field in.'
|
|
1995
|
+
})
|
|
1996
|
+
}]
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
const fb = feedback;
|
|
2000
|
+
// Every NEW node needs a description before it can ever be committed — that's what
|
|
2001
|
+
// makes search_nodes findable by natural language later, and it's the one thing that
|
|
2002
|
+
// reliably gets skipped if it's ever optional. Only NEW nodes are gated: a node
|
|
2003
|
+
// already in the graph (even if it was never described) is never blocked by an
|
|
2004
|
+
// unrelated later edit, or the backlog would stop every future commit. Checked BEFORE
|
|
2005
|
+
// any write — staging is untouched by a rejection here, so retrying costs nothing.
|
|
2006
|
+
//
|
|
2007
|
+
// Deduped by resolved node id, NOT checked entry-by-entry: a symbol can be staged
|
|
2008
|
+
// multiple times before one commit (edit it, edit it again — trail-check.ts covers
|
|
2009
|
+
// this accumulation deliberately), and an earlier entry with no description must not
|
|
2010
|
+
// block the commit when a LATER entry for that same node already carries one — e.g.
|
|
2011
|
+
// edit_node's own `description` param satisfying the gate on a second call after a
|
|
2012
|
+
// first call's description was rejected. A node counts as described if ANY of its
|
|
2013
|
+
// staged entries has one.
|
|
2014
|
+
const describedNodeIds = new Set(entries.filter(e => e.description).map(e => (0, staging_1.resolveEntryId)(db, e)));
|
|
2015
|
+
const undescribed = Array.from(new Set(entries.map(e => (0, staging_1.resolveEntryId)(db, e))))
|
|
2016
|
+
.filter(nodeId => !describedNodeIds.has(nodeId))
|
|
2017
|
+
.map(nodeId => ({ nodeId }))
|
|
2018
|
+
.filter(({ nodeId }) => !db.getNode(nodeId));
|
|
2019
|
+
if (undescribed.length > 0) {
|
|
1190
2020
|
return {
|
|
1191
|
-
|
|
2021
|
+
isError: true,
|
|
2022
|
+
content: [{
|
|
2023
|
+
type: 'text',
|
|
2024
|
+
text: JSON.stringify({
|
|
2025
|
+
committed: false,
|
|
2026
|
+
error: `${undescribed.length} new node(s) staged in this commit have no description. Call add_description with these node_ids, then call commit_changes again — nothing staged has been lost.`,
|
|
2027
|
+
undescribed_node_ids: undescribed.map(u => u.nodeId),
|
|
2028
|
+
example: {
|
|
2029
|
+
tool: 'add_description',
|
|
2030
|
+
descriptions: undescribed.slice(0, 3).map(u => ({
|
|
2031
|
+
node_id: u.nodeId,
|
|
2032
|
+
description: '1-3 sentences of what this does and the domain concepts involved — not a restatement of the name.'
|
|
2033
|
+
}))
|
|
2034
|
+
}
|
|
2035
|
+
}, null, 2)
|
|
2036
|
+
}]
|
|
1192
2037
|
};
|
|
1193
2038
|
}
|
|
1194
|
-
const
|
|
1195
|
-
(0, staging_1.
|
|
1196
|
-
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
2039
|
+
const reasoning = args.reasoning;
|
|
2040
|
+
const summary = await (0, staging_1.commitStagedChanges)(db, devmindPath, entries, reasoning); // no-ops cleanly when entries is empty
|
|
2041
|
+
(0, staging_1.clearStagedForSession)(devmindPath, sessionId);
|
|
2042
|
+
// A summary for both the workflow step and the activity message — the commit's own
|
|
2043
|
+
// reasoning takes priority when it has a what_changed; a commit that only touched
|
|
2044
|
+
// non-code files (CSS, XML, ...) falls back to naming the file(s) instead.
|
|
2045
|
+
const changeSummary = entries.length
|
|
2046
|
+
? (0, staging_1.summarizeEntriesForWorkflow)(entries, reasoning)
|
|
2047
|
+
: fileEdits.length === 1
|
|
2048
|
+
? path.basename(fileEdits[0].file_path)
|
|
2049
|
+
: `${fileEdits.length} file(s) updated`;
|
|
2050
|
+
// When THIS SESSION is bound to a workflow, auto-record the commit as a step — the agent
|
|
2051
|
+
// never needs a separate call for ordinary development work. The binding is read from the
|
|
2052
|
+
// local session rather than a global pointer, which is what stops two concurrent sessions
|
|
2053
|
+
// writing onto each other's timeline.
|
|
2054
|
+
//
|
|
2055
|
+
// `sessionId` and `node_ids` are both passed now. Neither used to be: the step's
|
|
2056
|
+
// session_id column existed and sat null on the path that creates nearly every step, and
|
|
2057
|
+
// `history_ids` was stored instead of node ids even though it cannot identify a commit
|
|
2058
|
+
// (two commits on one node within an hour merge into a single history row).
|
|
1199
2059
|
let workflowStepId = null;
|
|
1200
|
-
const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
2060
|
+
const boundWorkflowId = (0, activity_1.readSessionWorkflow)(devmindPath, sessionId);
|
|
2061
|
+
const boundWorkflow = boundWorkflowId ? db.getWorkflow(boundWorkflowId) : null;
|
|
2062
|
+
if (boundWorkflow) {
|
|
2063
|
+
const step = db.addWorkflowStep(boundWorkflow.id, {
|
|
2064
|
+
summary: changeSummary,
|
|
2065
|
+
reasoning: workflowReasoningText(reasoning),
|
|
2066
|
+
nodeIds: Array.from(new Set(summary.node_ids)),
|
|
2067
|
+
sessionId
|
|
1205
2068
|
});
|
|
1206
2069
|
workflowStepId = step.id;
|
|
1207
2070
|
}
|
|
2071
|
+
// Local, gitignored activity log — never reaches the shared graph. entries/node_ids are
|
|
2072
|
+
// 1:1 in order (commitStagedChanges pushes both from the same loop), so index-matching
|
|
2073
|
+
// recovers each edit's resolved node id. Entries with no code_before (the legacy
|
|
2074
|
+
// update_history path, which takes a snapshot with nothing to diff against) contribute
|
|
2075
|
+
// nothing here: there is no "before" to back up, so recording one would make revert
|
|
2076
|
+
// restore a guess. Whole-file
|
|
2077
|
+
// edits (fileEdits — nothing traced into the graph) are folded in alongside them, so
|
|
2078
|
+
// every file edit_node touched shows up here, not just the ones that became graph nodes.
|
|
2079
|
+
//
|
|
2080
|
+
// Sorted by staged_at (real edit_node call order), not left as "all node edits, then all
|
|
2081
|
+
// file edits": the same file can pick up both kinds across separate edit_node calls
|
|
2082
|
+
// before one commit, and the whole-file reconstruction (fileDiffForMessage) undoes a
|
|
2083
|
+
// file's edits newest-first — true chronological order is what makes that undo chain
|
|
2084
|
+
// correct instead of unwinding a state that was never actually current.
|
|
2085
|
+
const activityEdits = [
|
|
2086
|
+
...entries
|
|
2087
|
+
.map((e, i) => ({ entry: e, nodeId: summary.node_ids[i] }))
|
|
2088
|
+
.filter(({ entry }) => entry.code_before !== undefined)
|
|
2089
|
+
.map(({ entry, nodeId }) => ({
|
|
2090
|
+
id: crypto.randomUUID(),
|
|
2091
|
+
node_id: nodeId,
|
|
2092
|
+
file_path: entry.file_path,
|
|
2093
|
+
at: entry.staged_at || new Date().toISOString(),
|
|
2094
|
+
before: entry.code_before ?? '',
|
|
2095
|
+
after: entry.code_snapshot
|
|
2096
|
+
})),
|
|
2097
|
+
...fileEdits.map(fe => ({
|
|
2098
|
+
id: crypto.randomUUID(),
|
|
2099
|
+
node_id: db.toRepoRelativePath(fe.file_path),
|
|
2100
|
+
file_path: fe.file_path,
|
|
2101
|
+
at: fe.staged_at || new Date().toISOString(),
|
|
2102
|
+
before: fe.before,
|
|
2103
|
+
after: fe.after
|
|
2104
|
+
}))
|
|
2105
|
+
].sort((a, b) => a.at.localeCompare(b.at));
|
|
2106
|
+
const activityMessage = (0, activity_2.recordMessage)(devmindPath, {
|
|
2107
|
+
session_id: sessionId,
|
|
2108
|
+
developer: db.getDeveloperName(),
|
|
2109
|
+
request: requestText,
|
|
2110
|
+
summary: changeSummary,
|
|
2111
|
+
edits: activityEdits
|
|
2112
|
+
});
|
|
2113
|
+
// Mark these edits as already accounted for on the workflow. Without this, work that was
|
|
2114
|
+
// recorded automatically (because the session was bound) looks unattached to
|
|
2115
|
+
// `workflow_sync` later, and a well-meaning sync duplicates every step that was already
|
|
2116
|
+
// there. Both paths keep the same bookkeeping, so they can't disagree about what has
|
|
2117
|
+
// been attached.
|
|
2118
|
+
if (workflowStepId && boundWorkflow) {
|
|
2119
|
+
const consumed = activityEdits.map(e => e.id);
|
|
2120
|
+
const record = activityMessage.workflow_sync || [];
|
|
2121
|
+
const existing = record.find(w => w.workflow_id === boundWorkflow.id);
|
|
2122
|
+
if (existing) {
|
|
2123
|
+
existing.step_ids.push(workflowStepId);
|
|
2124
|
+
existing.synced_edit_ids.push(...consumed);
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
record.push({ workflow_id: boundWorkflow.id, step_ids: [workflowStepId], synced_edit_ids: consumed });
|
|
2128
|
+
}
|
|
2129
|
+
activityMessage.workflow_sync = record;
|
|
2130
|
+
(0, activity_1.saveMessage)(devmindPath, activityMessage);
|
|
2131
|
+
}
|
|
2132
|
+
// Route feedback to its two local, gitignored logs — "none" is skipped, not recorded, so
|
|
2133
|
+
// the logs only ever contain something an agent actually noticed. Never blocks or slows
|
|
2134
|
+
// the commit response: this is a synchronous local append, already done by the time the
|
|
2135
|
+
// response below is built.
|
|
2136
|
+
const isNone = (s) => s.trim().toLowerCase() === 'none';
|
|
2137
|
+
if (!isNone(fb.graph_problems)) {
|
|
2138
|
+
(0, feedback_1.appendGraphFeedback)(devmindPath, { session_id: sessionId, category: 'graph_problem', text: fb.graph_problems });
|
|
2139
|
+
}
|
|
2140
|
+
if (!isNone(fb.edge_problems)) {
|
|
2141
|
+
(0, feedback_1.appendGraphFeedback)(devmindPath, { session_id: sessionId, category: 'edge_problem', text: fb.edge_problems });
|
|
2142
|
+
}
|
|
2143
|
+
if (!isNone(fb.tools_used)) {
|
|
2144
|
+
(0, feedback_1.appendProductFeedback)(devmindPath, { session_id: sessionId, category: 'tools_used', text: fb.tools_used });
|
|
2145
|
+
}
|
|
2146
|
+
if (!isNone(fb.dropped_and_why)) {
|
|
2147
|
+
(0, feedback_1.appendProductFeedback)(devmindPath, { session_id: sessionId, category: 'dropped_and_why', text: fb.dropped_and_why });
|
|
2148
|
+
}
|
|
2149
|
+
if (!isNone(fb.devsmind_better)) {
|
|
2150
|
+
(0, feedback_1.appendProductFeedback)(devmindPath, { session_id: sessionId, category: 'devsmind_better', text: fb.devsmind_better });
|
|
2151
|
+
}
|
|
1208
2152
|
return {
|
|
1209
2153
|
content: [{
|
|
1210
2154
|
type: 'text',
|
|
@@ -1212,15 +2156,21 @@ function createMcpServer() {
|
|
|
1212
2156
|
committed: true,
|
|
1213
2157
|
message: `✅ Committed ${summary.nodes} node(s), ${summary.history_entries} history entr(ies), ${summary.edges_added} connection(s) resolved` +
|
|
1214
2158
|
(summary.missing_filled > 0 ? `, ${summary.missing_filled} missing node(s) auto-created.` : '.') +
|
|
1215
|
-
(
|
|
2159
|
+
(fileEdits.length ? ` ${fileEdits.length} non-code file(s) recorded to the activity log only.` : '') +
|
|
2160
|
+
(workflowStepId ? ` Logged as a step on workflow "${boundWorkflow.name}".` : '') +
|
|
2161
|
+
(otherSessionsPending > 0 ? ` ${otherSessionsPending} entr(y/ies) staged by another session were left pending, untouched by this commit.` : ''),
|
|
1216
2162
|
...summary,
|
|
1217
|
-
|
|
2163
|
+
file_edits_recorded: fileEdits.length,
|
|
2164
|
+
workflow_step_id: workflowStepId,
|
|
2165
|
+
activity_message_id: activityMessage.id,
|
|
2166
|
+
session_id: sessionId,
|
|
2167
|
+
other_sessions_pending: otherSessionsPending
|
|
1218
2168
|
}, null, 2)
|
|
1219
2169
|
}]
|
|
1220
2170
|
};
|
|
1221
2171
|
}
|
|
1222
2172
|
// ── Deprecated write handlers: NOT advertised in ListTools (superseded by
|
|
1223
|
-
//
|
|
2173
|
+
// edit_node/commit_changes), but retained so any direct/legacy call still works. ──
|
|
1224
2174
|
case 'add_node': {
|
|
1225
2175
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1226
2176
|
const rawNodeId = requireStr(args, 'node_id', 'add_node');
|
|
@@ -1287,7 +2237,12 @@ function createMcpServer() {
|
|
|
1287
2237
|
const graph = db.getGraph(nodeId, maxDepth, {
|
|
1288
2238
|
direction,
|
|
1289
2239
|
includeCode: args.include_code === true,
|
|
1290
|
-
|
|
2240
|
+
// Same NaN-means-unlimited hole this tool's replacement had; fixed here too even though
|
|
2241
|
+
// the tool is unadvertised, since the retained handler still serves legacy callers.
|
|
2242
|
+
// Keeps getGraph's own 60000 default — only the get_node_code path gets the lower one.
|
|
2243
|
+
codeCharBudget: args.code_char_budget !== undefined
|
|
2244
|
+
? clampInt(args.code_char_budget, 60_000, 0, 200_000)
|
|
2245
|
+
: undefined
|
|
1291
2246
|
});
|
|
1292
2247
|
return {
|
|
1293
2248
|
content: [{ type: 'text', text: JSON.stringify(graph, null, 2) }]
|
|
@@ -1295,19 +2250,57 @@ function createMcpServer() {
|
|
|
1295
2250
|
}
|
|
1296
2251
|
case 'search_nodes': {
|
|
1297
2252
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1298
|
-
|
|
1299
|
-
|
|
2253
|
+
// `query` and `pattern` are each optional individually — `db.searchNodes` itself throws
|
|
2254
|
+
// a clear error if BOTH are absent, which the outer try/catch below turns into a clean
|
|
2255
|
+
// isError response, so no duplicate validation here.
|
|
2256
|
+
const query = args.query ? String(args.query) : undefined;
|
|
2257
|
+
const pattern = args.pattern ? String(args.pattern) : undefined;
|
|
2258
|
+
const searchPath = args.path ? String(args.path) : undefined;
|
|
1300
2259
|
const caseInsensitive = args.case_insensitive !== false;
|
|
2260
|
+
// Clamped, because the unvalidated version failed in the most misleading way available:
|
|
2261
|
+
// `limit:"abc"` became NaN, `slice(0, NaN)` returned [], and the agent saw an empty
|
|
2262
|
+
// `files` bucket sitting next to a `files_total` of 40 — indistinguishable from "grep
|
|
2263
|
+
// found nothing". A negative offset paged from the END of the ranking; a huge limit was
|
|
2264
|
+
// an uncapped firehose, which is the very payload size this change set exists to bound.
|
|
2265
|
+
const offset = args.offset !== undefined ? clampInt(args.offset, 0, 0, Number.MAX_SAFE_INTEGER) : undefined;
|
|
2266
|
+
const limit = args.limit !== undefined ? clampInt(args.limit, 25, 1, 200) : undefined;
|
|
1301
2267
|
const db = getDatabase(devmindPath);
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
2268
|
+
// Returns the two-bucket `{ nodes, files, files_total, files_offset, nodes_total, hint?,
|
|
2269
|
+
// truncated? }` shape directly — including the empty+hint case when nothing matched
|
|
2270
|
+
// anywhere. Async: vector inference and the filesystem grep walk run concurrently inside
|
|
2271
|
+
// (a no-op vector fallback if ONNX absent; vector is skipped entirely when `query` is
|
|
2272
|
+
// absent — a regex has no meaning to embed).
|
|
2273
|
+
// `compact` is tri-state on purpose: true = always trim, false = never trim (give me
|
|
2274
|
+
// everything, I'll deal with it), omitted = decide from the actual size. Auto is the
|
|
2275
|
+
// default because the failure this fixes is one the caller can't predict — they don't
|
|
2276
|
+
// know a query will produce 56KB until it already has.
|
|
2277
|
+
const forceCompact = args.compact === true;
|
|
2278
|
+
const forbidCompact = args.compact === false;
|
|
2279
|
+
const payload = await db.searchNodes(query, { pattern, path: searchPath, case_insensitive: caseInsensitive, offset, limit, compact: forceCompact });
|
|
2280
|
+
// Note the absence of `null, 2`. Pretty-printing a payload whose bulk is deeply-nested
|
|
2281
|
+
// arrays of short strings spends 20-30% of the response on indentation that buys the
|
|
2282
|
+
// reader nothing — MCP clients parse this, they don't read it. Free size reduction with
|
|
2283
|
+
// zero semantic loss, applied before any tier is even considered.
|
|
2284
|
+
let text = JSON.stringify(payload);
|
|
2285
|
+
let out = payload;
|
|
2286
|
+
if (forceCompact) {
|
|
2287
|
+
out = { ...(0, database_1.toCompactSearchResult)(payload, 2), compacted: COMPACT_NOTE_FORCED };
|
|
2288
|
+
text = JSON.stringify(out);
|
|
2289
|
+
}
|
|
2290
|
+
else if (!forbidCompact && text.length > SEARCH_COMPACT_THRESHOLD) {
|
|
2291
|
+
// Tier 1 first — it drops what is bulk without being evidence, and usually that alone
|
|
2292
|
+
// is enough. Only fall through to the triage-only tier if the result is STILL too big,
|
|
2293
|
+
// rather than throwing away the sample lines (the part agents actually credit with
|
|
2294
|
+
// catching real bugs) the moment the payload crosses a line.
|
|
2295
|
+
out = { ...(0, database_1.toCompactSearchResult)(payload, 1), compacted: COMPACT_NOTE_TIER1 };
|
|
2296
|
+
text = JSON.stringify(out);
|
|
2297
|
+
if (text.length > SEARCH_COMPACT_THRESHOLD) {
|
|
2298
|
+
out = { ...(0, database_1.toCompactSearchResult)(payload, 2), compacted: COMPACT_NOTE_TIER2 };
|
|
2299
|
+
text = JSON.stringify(out);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
1309
2302
|
return {
|
|
1310
|
-
content: [{ type: 'text', text
|
|
2303
|
+
content: [{ type: 'text', text }]
|
|
1311
2304
|
};
|
|
1312
2305
|
}
|
|
1313
2306
|
case 'rename_node': {
|
|
@@ -1330,33 +2323,151 @@ function createMcpServer() {
|
|
|
1330
2323
|
content: [{ type: 'text', text: JSON.stringify({ success: true, deprecated: nodeId }) }]
|
|
1331
2324
|
};
|
|
1332
2325
|
}
|
|
1333
|
-
case '
|
|
2326
|
+
case 'read_graph_feedback': {
|
|
2327
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
2328
|
+
const includeProcessed = args.include_processed === true;
|
|
2329
|
+
const entries = (0, feedback_1.readGraphFeedback)(devmindPath, { includeProcessed });
|
|
2330
|
+
const clusters = (0, feedback_1.clusterGraphFeedback)(entries);
|
|
2331
|
+
return {
|
|
2332
|
+
content: [{ type: 'text', text: JSON.stringify({ total_entries: entries.length, clusters }, null, 2) }]
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
case 'mark_graph_feedback_processed': {
|
|
2336
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
2337
|
+
const ids = Array.isArray(args.feedback_ids) ? args.feedback_ids.map(String) : [];
|
|
2338
|
+
if (ids.length === 0) {
|
|
2339
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'mark_graph_feedback_processed needs a non-empty feedback_ids array.' }) }] };
|
|
2340
|
+
}
|
|
2341
|
+
(0, feedback_1.markGraphFeedbackProcessed)(devmindPath, ids);
|
|
2342
|
+
return {
|
|
2343
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, marked_processed: ids }) }]
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
case 'link_nodes': {
|
|
2347
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
2348
|
+
const fromNodeId = requireStr(args, 'from_node_id', 'link_nodes');
|
|
2349
|
+
const toNodeId = requireStr(args, 'to_node_id', 'link_nodes');
|
|
2350
|
+
const evidenceFile = requireStr(args, 'evidence_file', 'link_nodes');
|
|
2351
|
+
const evidenceSnippet = args.evidence_snippet ? String(args.evidence_snippet) : undefined;
|
|
2352
|
+
const db = getDatabase(devmindPath);
|
|
2353
|
+
const fromNode = db.getNode(fromNodeId);
|
|
2354
|
+
const toNode = db.getNode(toNodeId);
|
|
2355
|
+
if (!fromNode) {
|
|
2356
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `link_nodes: from_node_id not found: ${fromNodeId}` }) }] };
|
|
2357
|
+
}
|
|
2358
|
+
if (!toNode) {
|
|
2359
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `link_nodes: to_node_id not found: ${toNodeId}` }) }] };
|
|
2360
|
+
}
|
|
2361
|
+
const verified = verifyEvidence(evidenceFile, evidenceSnippet);
|
|
2362
|
+
if (!verified.ok) {
|
|
2363
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `link_nodes: evidence verification failed — ${verified.reason}` }) }] };
|
|
2364
|
+
}
|
|
2365
|
+
db.addConnection(fromNode.id, toNode.id);
|
|
2366
|
+
return {
|
|
2367
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, from_node_id: fromNode.id, to_node_id: toNode.id }) }]
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
case 'record_alias': {
|
|
2371
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
2372
|
+
const nodeId = requireStr(args, 'node_id', 'record_alias');
|
|
2373
|
+
const alias = requireStr(args, 'alias', 'record_alias');
|
|
2374
|
+
const evidenceFile = requireStr(args, 'evidence_file', 'record_alias');
|
|
2375
|
+
const evidenceSnippet = args.evidence_snippet ? String(args.evidence_snippet) : undefined;
|
|
2376
|
+
const db = getDatabase(devmindPath);
|
|
2377
|
+
const node = db.getNode(nodeId);
|
|
2378
|
+
if (!node) {
|
|
2379
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `record_alias: node_id not found: ${nodeId}` }) }] };
|
|
2380
|
+
}
|
|
2381
|
+
const verified = verifyEvidence(evidenceFile, evidenceSnippet);
|
|
2382
|
+
if (!verified.ok) {
|
|
2383
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `record_alias: evidence verification failed — ${verified.reason}` }) }] };
|
|
2384
|
+
}
|
|
2385
|
+
db.addAlias(node.id, alias);
|
|
2386
|
+
return {
|
|
2387
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, node_id: node.id, alias }) }]
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
case 'merge_nodes': {
|
|
1334
2391
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1335
|
-
const
|
|
1336
|
-
const
|
|
2392
|
+
const fromNodeId = requireStr(args, 'from_node_id', 'merge_nodes');
|
|
2393
|
+
const intoNodeId = requireStr(args, 'into_node_id', 'merge_nodes');
|
|
1337
2394
|
const db = getDatabase(devmindPath);
|
|
1338
|
-
|
|
2395
|
+
try {
|
|
2396
|
+
db.mergeNodes(fromNodeId, intoNodeId);
|
|
2397
|
+
}
|
|
2398
|
+
catch (err) {
|
|
2399
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }] };
|
|
2400
|
+
}
|
|
1339
2401
|
return {
|
|
1340
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
2402
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, from_node_id: fromNodeId, into_node_id: intoNodeId }) }]
|
|
1341
2403
|
};
|
|
1342
2404
|
}
|
|
1343
|
-
case '
|
|
2405
|
+
case 'split_node': {
|
|
1344
2406
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1345
|
-
const
|
|
1346
|
-
const
|
|
2407
|
+
const nodeId = requireStr(args, 'node_id', 'split_node');
|
|
2408
|
+
const newSymbols = Array.isArray(args.new_symbols) ? args.new_symbols.map(String) : [];
|
|
2409
|
+
if (newSymbols.length === 0) {
|
|
2410
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'split_node needs a non-empty new_symbols array.' }) }] };
|
|
2411
|
+
}
|
|
1347
2412
|
const db = getDatabase(devmindPath);
|
|
1348
|
-
const
|
|
2413
|
+
const result = (0, edges_1.splitNode)(db, nodeId, newSymbols);
|
|
1349
2414
|
return {
|
|
1350
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
2415
|
+
content: [{ type: 'text', text: JSON.stringify({ success: result.created.length > 0, ...result }) }]
|
|
1351
2416
|
};
|
|
1352
2417
|
}
|
|
1353
|
-
case '
|
|
2418
|
+
case 'create_missing_node': {
|
|
1354
2419
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1355
|
-
const
|
|
2420
|
+
const filePath = requireStr(args, 'file_path', 'create_missing_node');
|
|
2421
|
+
const symbolName = requireStr(args, 'symbol_name', 'create_missing_node');
|
|
1356
2422
|
const db = getDatabase(devmindPath);
|
|
1357
|
-
const
|
|
2423
|
+
const derived = (0, ast_1.extractNodeFromFile)(filePath, symbolName);
|
|
2424
|
+
if (!derived) {
|
|
2425
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `create_missing_node: could not locate '${symbolName}' in ${filePath} — not a parseable file, or the symbol does not exist there.` }) }] };
|
|
2426
|
+
}
|
|
2427
|
+
const nodeId = `${db.toRepoRelativePath(filePath)}#${symbolName}`;
|
|
2428
|
+
db.upsertNode({ id: nodeId, name: derived.name, type: derived.type, file_path: filePath, signature: derived.signature });
|
|
2429
|
+
db.updateHistory({
|
|
2430
|
+
node_id: nodeId,
|
|
2431
|
+
code_snapshot: derived.codeSnapshot,
|
|
2432
|
+
reasoning: {
|
|
2433
|
+
what_changed: 'Created from a batch graph-fix session correction (create_missing_node)',
|
|
2434
|
+
why: 'A graph-problem report pointed at a real symbol the indexer never extracted',
|
|
2435
|
+
goal: 'Fill an indexing gap deterministically from the AST',
|
|
2436
|
+
model: 'ast'
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
1358
2439
|
return {
|
|
1359
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
2440
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, node_id: nodeId }) }]
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
case 'flag_indexer_rule': {
|
|
2444
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
2445
|
+
const pattern = requireStr(args, 'pattern', 'flag_indexer_rule');
|
|
2446
|
+
const evidenceCount = typeof args.evidence_count === 'number' ? args.evidence_count : Number(args.evidence_count) || 0;
|
|
2447
|
+
const examples = Array.isArray(args.examples) ? args.examples.map(String) : [];
|
|
2448
|
+
(0, feedback_1.appendIndexerRuleCandidate)(devmindPath, { pattern, evidence_count: evidenceCount, examples });
|
|
2449
|
+
return {
|
|
2450
|
+
content: [{ type: 'text', text: JSON.stringify({ success: true, pattern }) }]
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
case 'get_activity_log': {
|
|
2454
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
2455
|
+
const db = getDatabase(devmindPath);
|
|
2456
|
+
const rawSource = args.source ? String(args.source) : 'auto';
|
|
2457
|
+
if (!['auto', 'local', 'graph', 'both'].includes(rawSource)) {
|
|
2458
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `get_activity_log: source must be one of auto|local|graph|both, got "${rawSource}"` }) }] };
|
|
2459
|
+
}
|
|
2460
|
+
const result = (0, activity_graph_1.resolveActivityLog)(db, devmindPath, rawSource, {
|
|
2461
|
+
developer: args.developer ? String(args.developer) : undefined,
|
|
2462
|
+
sessionId: args.session_id ? String(args.session_id) : undefined,
|
|
2463
|
+
sinceHours: args.since_hours !== undefined ? Number(args.since_hours) : undefined,
|
|
2464
|
+
since: args.since ? String(args.since) : undefined,
|
|
2465
|
+
until: args.until ? String(args.until) : undefined,
|
|
2466
|
+
requirementContains: args.requirement_contains ? String(args.requirement_contains) : undefined,
|
|
2467
|
+
limit: args.limit !== undefined ? Number(args.limit) : undefined
|
|
2468
|
+
});
|
|
2469
|
+
return {
|
|
2470
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
1360
2471
|
};
|
|
1361
2472
|
}
|
|
1362
2473
|
case 'search_decisions': {
|
|
@@ -1390,12 +2501,15 @@ function createMcpServer() {
|
|
|
1390
2501
|
case 'get_visualizer_url': {
|
|
1391
2502
|
const devmindPath = path.resolve(resolveDevmindPath(args.devmind_path));
|
|
1392
2503
|
const devmindPathEscaped = encodeURIComponent(devmindPath);
|
|
2504
|
+
// ONE url. This used to also advertise `/3d?path=…`, left over from when 2D and 3D were
|
|
2505
|
+
// separate pages — that route no longer exists, so anything following it got a 404. The
|
|
2506
|
+
// 3D toggle lives inside the Graph tab of the single-page app now.
|
|
1393
2507
|
return {
|
|
1394
2508
|
content: [{
|
|
1395
2509
|
type: 'text',
|
|
1396
2510
|
text: JSON.stringify({
|
|
1397
|
-
|
|
1398
|
-
|
|
2511
|
+
url: `http://localhost:${exports.DEVSMIND_PORT}/?path=${devmindPathEscaped}`,
|
|
2512
|
+
note: 'One page: Chat and Graph tabs. The Graph tab has a 2D/3D toggle and a whole-graph overlay — there are no separate 2D/3D URLs.'
|
|
1399
2513
|
}, null, 2)
|
|
1400
2514
|
}]
|
|
1401
2515
|
};
|
|
@@ -1422,92 +2536,247 @@ function createMcpServer() {
|
|
|
1422
2536
|
case 'workflow_add_step': {
|
|
1423
2537
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1424
2538
|
const db = getDatabase(devmindPath);
|
|
1425
|
-
const workflowId = args.workflow_id ? String(args.workflow_id) :
|
|
2539
|
+
const workflowId = args.workflow_id ? String(args.workflow_id) : (0, activity_1.readSessionWorkflow)(devmindPath, sessionId);
|
|
1426
2540
|
if (!workflowId) {
|
|
1427
2541
|
return {
|
|
1428
2542
|
isError: true,
|
|
1429
|
-
content: [{ type: 'text', text: JSON.stringify({ error: '
|
|
2543
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'This session is not bound to a workflow and no workflow_id was given. Call workflow_bind first, or pass workflow_id explicitly.' }) }]
|
|
1430
2544
|
};
|
|
1431
2545
|
}
|
|
2546
|
+
// `doc_paths` are validated, not trusted. A step is committed and read by teammates, so a
|
|
2547
|
+
// path outside the configured repos is worse than useless — it resolves to nothing on
|
|
2548
|
+
// anyone else's machine. Rejected outright rather than stored and quietly broken later.
|
|
2549
|
+
const rawDocs = Array.isArray(args.doc_paths) ? args.doc_paths.map(String) : [];
|
|
2550
|
+
const workspaceRoot = path.dirname(devmindPath);
|
|
2551
|
+
const docPaths = [];
|
|
2552
|
+
for (const raw of rawDocs) {
|
|
2553
|
+
const abs = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(workspaceRoot, raw);
|
|
2554
|
+
if (!db.isPathAllowed(abs)) {
|
|
2555
|
+
return {
|
|
2556
|
+
isError: true,
|
|
2557
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_path resolves outside this project's configured repos, so it would not exist for anyone else: ${raw}` }) }]
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
if (!fs.existsSync(abs)) {
|
|
2561
|
+
return {
|
|
2562
|
+
isError: true,
|
|
2563
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_path does not exist on disk: ${raw}` }) }]
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
docPaths.push(db.toRepoRelativePath(abs));
|
|
2567
|
+
}
|
|
1432
2568
|
const step = db.addWorkflowStep(workflowId, {
|
|
1433
2569
|
summary: requireStr(args, 'summary', 'workflow_add_step'),
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
2570
|
+
reasoning: args.reasoning ? String(args.reasoning).slice(0, STEP_REASONING_CAP) : undefined,
|
|
2571
|
+
nodeIds: Array.isArray(args.node_ids) ? args.node_ids.map(String) : undefined,
|
|
2572
|
+
docPaths: docPaths.length ? docPaths : undefined,
|
|
2573
|
+
sessionId
|
|
1437
2574
|
});
|
|
1438
2575
|
return { content: [{ type: 'text', text: JSON.stringify({ status: 'added', step }, null, 2) }] };
|
|
1439
2576
|
}
|
|
1440
|
-
case '
|
|
2577
|
+
case 'workflow_bind': {
|
|
1441
2578
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1442
2579
|
const db = getDatabase(devmindPath);
|
|
1443
|
-
const
|
|
2580
|
+
const rawId = args.workflow_id ? String(args.workflow_id) : null;
|
|
2581
|
+
if (rawId) {
|
|
2582
|
+
const workflow = db.getWorkflow(rawId);
|
|
2583
|
+
if (!workflow) {
|
|
2584
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `workflow_bind: workflow_id not found: ${rawId}` }) }] };
|
|
2585
|
+
}
|
|
2586
|
+
(0, activity_1.bindSessionWorkflow)(devmindPath, sessionId, workflow.id, db.getDeveloperName());
|
|
2587
|
+
return {
|
|
2588
|
+
content: [{
|
|
2589
|
+
type: 'text',
|
|
2590
|
+
text: JSON.stringify({
|
|
2591
|
+
status: 'bound',
|
|
2592
|
+
workflow,
|
|
2593
|
+
message: `This session is now on "${workflow.name}". Every commit_changes from here adds a step to it, until you unbind (call workflow_bind with no workflow_id). Binding is local to this session — it does not move anyone else's.`
|
|
2594
|
+
}, null, 2)
|
|
2595
|
+
}]
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
(0, activity_1.bindSessionWorkflow)(devmindPath, sessionId, null, db.getDeveloperName());
|
|
1444
2599
|
return {
|
|
1445
|
-
content: [{
|
|
2600
|
+
content: [{
|
|
2601
|
+
type: 'text',
|
|
2602
|
+
text: JSON.stringify({
|
|
2603
|
+
status: 'unbound',
|
|
2604
|
+
message: 'This session is no longer on a workflow. Later commits attach to nothing — if that turns out to be wrong, workflow_sync can attach them afterwards.'
|
|
2605
|
+
}, null, 2)
|
|
2606
|
+
}]
|
|
1446
2607
|
};
|
|
1447
2608
|
}
|
|
1448
|
-
case 'workflow_resume': {
|
|
1449
|
-
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1450
|
-
const db = getDatabase(devmindPath);
|
|
1451
|
-
const workflow = db.resumeWorkflow(requireStr(args, 'workflow_id', 'workflow_resume'));
|
|
1452
|
-
return { content: [{ type: 'text', text: JSON.stringify({ status: 'active', workflow }, null, 2) }] };
|
|
1453
|
-
}
|
|
1454
2609
|
case 'workflow_list': {
|
|
1455
2610
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1456
2611
|
const db = getDatabase(devmindPath);
|
|
1457
|
-
const
|
|
1458
|
-
const
|
|
1459
|
-
|
|
2612
|
+
const query = args.query ? String(args.query) : undefined;
|
|
2613
|
+
const includeArchived = args.include_archived === true;
|
|
2614
|
+
const limit = clampInt(args.limit, 25, 1, 200);
|
|
2615
|
+
const offset = clampInt(args.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
2616
|
+
const total = db.countWorkflows({ query, includeArchived });
|
|
2617
|
+
const workflows = db.listWorkflows({ query, includeArchived, limit, offset });
|
|
2618
|
+
const payload = {
|
|
2619
|
+
workflows,
|
|
2620
|
+
total,
|
|
2621
|
+
offset,
|
|
2622
|
+
bound_workflow_id: (0, activity_1.readSessionWorkflow)(devmindPath, sessionId)
|
|
2623
|
+
};
|
|
2624
|
+
if (offset + workflows.length < total) {
|
|
2625
|
+
payload.truncated = true;
|
|
2626
|
+
payload.hint = `${total} workflows match; showing ${offset + 1}-${offset + workflows.length}. Pass offset:${offset + workflows.length} for the next page, or narrow with query.`;
|
|
2627
|
+
}
|
|
2628
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
1460
2629
|
}
|
|
1461
2630
|
case 'workflow_get_context': {
|
|
1462
2631
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1463
2632
|
const db = getDatabase(devmindPath);
|
|
2633
|
+
const lastN = clampInt(args.last_n, 0, 0, 500);
|
|
1464
2634
|
const context = db.getWorkflowContext(requireStr(args, 'workflow_id', 'workflow_get_context'), {
|
|
1465
|
-
|
|
2635
|
+
last_n: lastN > 0 ? lastN : undefined,
|
|
2636
|
+
limit: lastN > 0 ? undefined : clampInt(args.limit, 50, 1, 500),
|
|
2637
|
+
offset: lastN > 0 ? undefined : clampInt(args.offset, 0, 0, Number.MAX_SAFE_INTEGER)
|
|
1466
2638
|
});
|
|
1467
|
-
|
|
2639
|
+
const payload = { ...context };
|
|
2640
|
+
if (context.steps_offset + context.steps.length < context.steps_total) {
|
|
2641
|
+
payload.truncated = true;
|
|
2642
|
+
payload.hint = `${context.steps_total} steps total, showing ${context.steps_offset + 1}-${context.steps_offset + context.steps.length}. Pass offset:${context.steps_offset + context.steps.length} for the next page, or last_n to read the most recent steps instead.`;
|
|
2643
|
+
}
|
|
2644
|
+
// Same shrink-to-fit contract as search_nodes/list_nodes: trim the biggest field first
|
|
2645
|
+
// (per-step reasoning), then the node lists, and always say which happened. Counts are
|
|
2646
|
+
// never touched, so a trimmed story can't be mistaken for the whole one.
|
|
2647
|
+
let text = JSON.stringify(payload);
|
|
2648
|
+
if (text.length > SEARCH_COMPACT_THRESHOLD) {
|
|
2649
|
+
payload.steps = context.steps.map(({ reasoning, ...rest }) => rest);
|
|
2650
|
+
payload.compacted = 'Per-step reasoning was dropped to fit. Counts are exact. Read fewer steps at a time (last_n, or limit/offset) to get the reasoning back.';
|
|
2651
|
+
text = JSON.stringify(payload);
|
|
2652
|
+
if (text.length > SEARCH_COMPACT_THRESHOLD) {
|
|
2653
|
+
payload.steps = context.steps.map(s => ({ step_index: s.step_index, summary: s.summary, created_at: s.created_at }));
|
|
2654
|
+
payload.compacted = 'Trimmed hard to fit: step index, summary and date only. Counts are exact. Read fewer steps at a time (last_n, or limit/offset) for reasoning and node lists.';
|
|
2655
|
+
text = JSON.stringify(payload);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
return { content: [{ type: 'text', text }] };
|
|
1468
2659
|
}
|
|
1469
|
-
case '
|
|
2660
|
+
case 'workflow_archive': {
|
|
1470
2661
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1471
2662
|
const db = getDatabase(devmindPath);
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
type: requireStr(args, 'type', 'workflow_add_artifact'),
|
|
1475
|
-
sourceName: requireStr(args, 'source_name', 'workflow_add_artifact'),
|
|
1476
|
-
content: requireStr(args, 'content', 'workflow_add_artifact')
|
|
1477
|
-
});
|
|
1478
|
-
return { content: [{ type: 'text', text: JSON.stringify({ status: 'added', artifact }, null, 2) }] };
|
|
2663
|
+
const workflow = db.setWorkflowArchived(requireStr(args, 'workflow_id', 'workflow_archive'), args.archived !== false);
|
|
2664
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: workflow.archived ? 'archived' : 'unarchived', workflow }, null, 2) }] };
|
|
1479
2665
|
}
|
|
1480
|
-
case '
|
|
2666
|
+
case 'workflow_sync': {
|
|
1481
2667
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1482
2668
|
const db = getDatabase(devmindPath);
|
|
1483
|
-
const workflowId = requireStr(args, 'workflow_id', '
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
const
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
2669
|
+
const workflowId = requireStr(args, 'workflow_id', 'workflow_sync');
|
|
2670
|
+
const workflow = db.getWorkflow(workflowId);
|
|
2671
|
+
if (!workflow) {
|
|
2672
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: `workflow_sync: workflow_id not found: ${workflowId}` }) }] };
|
|
2673
|
+
}
|
|
2674
|
+
// Reads the LOCAL activity log — which is what makes this a real sync rather than the
|
|
2675
|
+
// old version, where the agent hand-assembled a `steps` array from its own context and
|
|
2676
|
+
// the server just wrote whatever it was handed.
|
|
2677
|
+
const sinceHours = clampInt(args.since_hours, 0, 0, 24 * 90);
|
|
2678
|
+
const scopeSession = args.all_sessions === true ? undefined : sessionId;
|
|
2679
|
+
const log = (0, activity_1.queryActivityLog)(devmindPath, {
|
|
2680
|
+
sessionId: scopeSession,
|
|
2681
|
+
sinceHours: sinceHours > 0 ? sinceHours : undefined,
|
|
2682
|
+
limit: 200
|
|
2683
|
+
});
|
|
2684
|
+
if (log.entries.length === 0) {
|
|
2685
|
+
return {
|
|
2686
|
+
content: [{
|
|
2687
|
+
type: 'text',
|
|
2688
|
+
text: JSON.stringify({
|
|
2689
|
+
status: 'nothing_to_sync',
|
|
2690
|
+
message: 'No local activity found for this scope. The activity log lives in .devmind/local/ and is not shared, so there is nothing to attach if it was cleared or the work happened on another machine.'
|
|
2691
|
+
}, null, 2)
|
|
2692
|
+
}]
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
const explicitIds = Array.isArray(args.message_ids) ? new Set(args.message_ids.map(String)) : null;
|
|
2696
|
+
const proposals = [];
|
|
2697
|
+
for (const entry of log.entries) {
|
|
2698
|
+
if (explicitIds && !explicitIds.has(entry.id))
|
|
1501
2699
|
continue;
|
|
2700
|
+
const message = (0, activity_1.readMessage)(devmindPath, entry.id);
|
|
2701
|
+
if (!message)
|
|
2702
|
+
continue;
|
|
2703
|
+
// Already-consumed edits are skipped rather than whole messages: a message keeps
|
|
2704
|
+
// growing after it is first synced, so "have I seen this message" would strand
|
|
2705
|
+
// everything appended later.
|
|
2706
|
+
const alreadySynced = new Set((message.workflow_sync || []).filter(w => w.workflow_id === workflowId).flatMap(w => w.synced_edit_ids));
|
|
2707
|
+
const fresh = message.edits.filter(e => !alreadySynced.has(e.id));
|
|
2708
|
+
if (fresh.length === 0)
|
|
2709
|
+
continue;
|
|
2710
|
+
// A whole-file edit stores a repo-relative PATH in node_id, not a node id — keeping
|
|
2711
|
+
// those would put strings in the step that get_node_code can never resolve.
|
|
2712
|
+
const nodeIds = Array.from(new Set(fresh.map(e => e.node_id))).filter(id => !!db.getNode(id));
|
|
2713
|
+
proposals.push({
|
|
2714
|
+
message_id: message.id,
|
|
2715
|
+
// `summary` deliberately, never `request`: request is the developer's verbatim
|
|
2716
|
+
// prompt, and .devmind/local/ is gitignored precisely because that text is private.
|
|
2717
|
+
// Workflow steps are committed and read by the whole team.
|
|
2718
|
+
summary: message.summary,
|
|
2719
|
+
node_ids: nodeIds,
|
|
2720
|
+
edit_ids: fresh.map(e => e.id),
|
|
2721
|
+
created_at: message.created_at
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
if (proposals.length === 0) {
|
|
2725
|
+
return {
|
|
2726
|
+
content: [{
|
|
2727
|
+
type: 'text',
|
|
2728
|
+
text: JSON.stringify({ status: 'nothing_to_sync', message: `Everything in scope is already on "${workflow.name}".` }, null, 2)
|
|
2729
|
+
}]
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
// Dry run by default. MCP has no interactive primitive, so "show, then confirm" is two
|
|
2733
|
+
// calls — and defaulting to the safe one means a mis-scoped sync costs a turn rather
|
|
2734
|
+
// than silently rewriting a shared timeline.
|
|
2735
|
+
if (args.confirm !== true) {
|
|
2736
|
+
return {
|
|
2737
|
+
content: [{
|
|
2738
|
+
type: 'text',
|
|
2739
|
+
text: JSON.stringify({
|
|
2740
|
+
status: 'proposed',
|
|
2741
|
+
workflow: { id: workflow.id, name: workflow.name },
|
|
2742
|
+
proposed_steps: proposals.map(p => ({ message_id: p.message_id, summary: p.summary, node_ids: p.node_ids, created_at: p.created_at })),
|
|
2743
|
+
message: `Nothing written yet. Show these ${proposals.length} step(s) to the user, then call again with confirm:true (optionally narrowing with message_ids) to attach them.`
|
|
2744
|
+
}, null, 2)
|
|
2745
|
+
}]
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
const created = [];
|
|
2749
|
+
for (const p of proposals) {
|
|
2750
|
+
const step = db.addWorkflowStep(workflowId, {
|
|
2751
|
+
summary: p.summary,
|
|
2752
|
+
nodeIds: p.node_ids.length ? p.node_ids : undefined,
|
|
2753
|
+
sessionId
|
|
2754
|
+
});
|
|
2755
|
+
const message = (0, activity_1.readMessage)(devmindPath, p.message_id);
|
|
2756
|
+
if (message) {
|
|
2757
|
+
const record = message.workflow_sync || [];
|
|
2758
|
+
const existing = record.find(w => w.workflow_id === workflowId);
|
|
2759
|
+
if (existing) {
|
|
2760
|
+
existing.step_ids.push(step.id);
|
|
2761
|
+
existing.synced_edit_ids.push(...p.edit_ids);
|
|
2762
|
+
}
|
|
2763
|
+
else {
|
|
2764
|
+
record.push({ workflow_id: workflowId, step_ids: [step.id], synced_edit_ids: [...p.edit_ids] });
|
|
2765
|
+
}
|
|
2766
|
+
message.workflow_sync = record;
|
|
2767
|
+
(0, activity_1.saveMessage)(devmindPath, message);
|
|
1502
2768
|
}
|
|
1503
|
-
|
|
1504
|
-
added.push(db.addWorkflowStep(workflowId, { summary, pendingTasks, historyIds }));
|
|
2769
|
+
created.push({ step_id: step.id, message_id: p.message_id });
|
|
1505
2770
|
}
|
|
1506
2771
|
return {
|
|
1507
2772
|
content: [{
|
|
1508
2773
|
type: 'text',
|
|
1509
2774
|
text: JSON.stringify({
|
|
1510
|
-
status: 'synced',
|
|
2775
|
+
status: 'synced',
|
|
2776
|
+
workflow: { id: workflow.id, name: workflow.name },
|
|
2777
|
+
steps_added: created.length,
|
|
2778
|
+
steps: created,
|
|
2779
|
+
message: 'Re-running this is a no-op — the edits behind these steps are marked as consumed.'
|
|
1511
2780
|
}, null, 2)
|
|
1512
2781
|
}]
|
|
1513
2782
|
};
|
|
@@ -1518,43 +2787,78 @@ function createMcpServer() {
|
|
|
1518
2787
|
const result = (0, workflow_import_1.importWorkflowDocs)(db, args.folder_path ? String(args.folder_path) : undefined, args.file_path ? String(args.file_path) : undefined);
|
|
1519
2788
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
1520
2789
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
});
|
|
1529
|
-
return { content: [{ type: 'text', text: JSON.stringify({ results, total_workflows_matched: results.length }, null, 2) }] };
|
|
1530
|
-
}
|
|
1531
|
-
case 'workflow_read_artifact': {
|
|
2790
|
+
// NOTE: `workflow_pause` and `workflow_resume` are retained as thin aliases for
|
|
2791
|
+
// `workflow_bind`. Their old meaning — move a single global pointer, pausing whoever else
|
|
2792
|
+
// held it — no longer exists, and could not be reproduced without reintroducing the bug
|
|
2793
|
+
// that made two sessions overwrite each other. Binding this session is the honest
|
|
2794
|
+
// equivalent of what a caller wanted from them.
|
|
2795
|
+
case 'workflow_pause':
|
|
2796
|
+
case 'workflow_resume': {
|
|
1532
2797
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1533
2798
|
const db = getDatabase(devmindPath);
|
|
1534
|
-
|
|
1535
|
-
|
|
2799
|
+
if (name === 'workflow_pause') {
|
|
2800
|
+
(0, activity_1.bindSessionWorkflow)(devmindPath, sessionId, null, db.getDeveloperName());
|
|
2801
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'unbound', message: 'workflow_pause now unbinds THIS session only — use workflow_bind.' }, null, 2) }] };
|
|
2802
|
+
}
|
|
2803
|
+
const workflow = db.getWorkflow(requireStr(args, 'workflow_id', 'workflow_resume'));
|
|
2804
|
+
if (!workflow) {
|
|
2805
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'workflow_resume: workflow_id not found.' }) }] };
|
|
2806
|
+
}
|
|
2807
|
+
(0, activity_1.bindSessionWorkflow)(devmindPath, sessionId, workflow.id, db.getDeveloperName());
|
|
2808
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'bound', workflow, message: 'workflow_resume now binds THIS session only — use workflow_bind.' }, null, 2) }] };
|
|
1536
2809
|
}
|
|
1537
|
-
case '
|
|
2810
|
+
case 'start_session': {
|
|
1538
2811
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1539
2812
|
const db = getDatabase(devmindPath);
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
2813
|
+
const label = args.label ? String(args.label) : undefined;
|
|
2814
|
+
const session = (0, activity_1.createSession)(devmindPath, sessionId, db.getDeveloperName(), label);
|
|
2815
|
+
// Offer to continue whatever was last worked on — but ONLY when there is something to
|
|
2816
|
+
// offer. Asking every session "are you continuing a workflow?" would put friction on the
|
|
2817
|
+
// large majority that are not, so when nothing recent exists these keys are simply
|
|
2818
|
+
// absent and the agent has nothing to raise.
|
|
2819
|
+
const payload = {
|
|
2820
|
+
session_id: session.id,
|
|
2821
|
+
started_at: session.started_at,
|
|
2822
|
+
label: session.label ?? null,
|
|
2823
|
+
message: 'Session started — pass this session_id on every DevsMind call for the rest of this conversation.'
|
|
2824
|
+
};
|
|
2825
|
+
const lastId = (0, activity_1.lastBoundWorkflowId)(devmindPath, sessionId);
|
|
2826
|
+
const resumable = lastId ? db.getWorkflow(lastId) : null;
|
|
2827
|
+
if (resumable && !resumable.archived) {
|
|
2828
|
+
payload.resumable_workflow = { id: resumable.id, name: resumable.name, description: resumable.description, updated_at: resumable.updated_at };
|
|
2829
|
+
payload.resumable_prompt = `The last workflow worked on here was "${resumable.name}" (${resumable.updated_at.slice(0, 10)}). Ask the user whether this session continues it — if yes call workflow_bind with that id, if no just carry on unbound.`;
|
|
2830
|
+
}
|
|
2831
|
+
return {
|
|
2832
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }]
|
|
2833
|
+
};
|
|
1546
2834
|
}
|
|
1547
2835
|
default:
|
|
1548
2836
|
throw new Error(`Tool not found: ${name}`);
|
|
1549
2837
|
}
|
|
2838
|
+
};
|
|
2839
|
+
let result;
|
|
2840
|
+
try {
|
|
2841
|
+
result = await run();
|
|
1550
2842
|
}
|
|
1551
2843
|
catch (err) {
|
|
1552
2844
|
console.error(`[DevsMind Error] Tool execution failed: ${err.message}`);
|
|
1553
|
-
|
|
2845
|
+
result = {
|
|
1554
2846
|
isError: true,
|
|
1555
2847
|
content: [{ type: 'text', text: `Error: ${err.message}` }]
|
|
1556
2848
|
};
|
|
1557
2849
|
}
|
|
2850
|
+
// Echo the session id so it survives context compaction — but only when there IS one. Exempt
|
|
2851
|
+
// read-only tools run with sessionId '' (falsy); appending a "reuse this" line for a session
|
|
2852
|
+
// that was never minted would be misleading, so skip it for them.
|
|
2853
|
+
return {
|
|
2854
|
+
...result,
|
|
2855
|
+
content: [
|
|
2856
|
+
...(result.content ?? []),
|
|
2857
|
+
...(sessionId
|
|
2858
|
+
? [{ type: 'text', text: `devsmind_session_id: ${sessionId} (required on every DevsMind write call for the rest of this conversation — reuse this exact value)` }]
|
|
2859
|
+
: [])
|
|
2860
|
+
]
|
|
2861
|
+
};
|
|
1558
2862
|
});
|
|
1559
2863
|
return server;
|
|
1560
2864
|
}
|
|
@@ -1572,14 +2876,51 @@ function registerShutdownHandlers(httpServer) {
|
|
|
1572
2876
|
process.on('SIGINT', shutdown);
|
|
1573
2877
|
process.on('SIGTERM', shutdown);
|
|
1574
2878
|
}
|
|
1575
|
-
// ── HTTP mode (default) — port
|
|
2879
|
+
// ── HTTP mode (default) — port 4513 ──────────────────────────────────────────
|
|
1576
2880
|
/**
|
|
1577
|
-
* Start DevsMind as an HTTP MCP server on port
|
|
1578
|
-
* IDEs connect via: http://localhost:
|
|
2881
|
+
* Start DevsMind as an HTTP MCP server on port 4513.
|
|
2882
|
+
* IDEs connect via: http://localhost:4513/mcp
|
|
1579
2883
|
*
|
|
1580
2884
|
* Port mnemonic: devs→45 (D=4,E=5) + mind→13 (M=13) = 4513
|
|
1581
2885
|
*/
|
|
1582
|
-
|
|
2886
|
+
/** True only for an Origin this server could itself have served — it binds loopback only. */
|
|
2887
|
+
function isLocalOrigin(origin, port) {
|
|
2888
|
+
try {
|
|
2889
|
+
const u = new URL(origin);
|
|
2890
|
+
return (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]')
|
|
2891
|
+
&& u.port === String(port);
|
|
2892
|
+
}
|
|
2893
|
+
catch {
|
|
2894
|
+
return false;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Which edit of an entry can be reverted, and why the newest one can't when it can't.
|
|
2899
|
+
*
|
|
2900
|
+
* Only the newest qualifies: every edit after an older one was written against the code it
|
|
2901
|
+
* produced, so restoring its "before" would silently drop that later work. It also has to still
|
|
2902
|
+
* match the file — comparing directly rather than reading `snapshot_outdated`, which conflates
|
|
2903
|
+
* "drifted" with "couldn't be checked" and would wave through a symbol that no longer parses.
|
|
2904
|
+
*/
|
|
2905
|
+
function revertability(db, entry) {
|
|
2906
|
+
if (!entry.edits.length)
|
|
2907
|
+
return { revertableIndex: -1 };
|
|
2908
|
+
const last = entry.edits[entry.edits.length - 1];
|
|
2909
|
+
const live = db.getLiveCode(entry.node_id);
|
|
2910
|
+
if (live.source !== 'live' || live.code === undefined) {
|
|
2911
|
+
return { revertableIndex: -1, blockedReason: 'Could not read this symbol from its file to confirm what is there now — it may have been renamed, moved, or deleted. Use git to restore it.' };
|
|
2912
|
+
}
|
|
2913
|
+
if (live.code !== last.after) {
|
|
2914
|
+
return { revertableIndex: -1, blockedReason: 'The code has changed since this was recorded — reverting would discard the newer change. Use git to restore it.' };
|
|
2915
|
+
}
|
|
2916
|
+
return { revertableIndex: entry.edits.length - 1 };
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Builds the Express app (view UI + activity/revert APIs + the stateless /mcp endpoint) without
|
|
2920
|
+
* binding a port. Exported so tests can drive it in-process via supertest; `runHttpMcpServer`
|
|
2921
|
+
* below is the only caller that actually `listen()`s on it.
|
|
2922
|
+
*/
|
|
2923
|
+
function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
1583
2924
|
const app = (0, express_1.default)();
|
|
1584
2925
|
app.use(express_1.default.json());
|
|
1585
2926
|
// Health-check endpoint
|
|
@@ -1587,42 +2928,44 @@ async function runHttpMcpServer(port = exports.DEVSMIND_PORT) {
|
|
|
1587
2928
|
res.json({
|
|
1588
2929
|
status: 'ok',
|
|
1589
2930
|
name: 'devsmind-mcp-server',
|
|
1590
|
-
version:
|
|
2931
|
+
version: version_1.DEVSMIND_VERSION,
|
|
1591
2932
|
port,
|
|
1592
2933
|
transport: 'http+streamable',
|
|
1593
2934
|
endpoint: `http://localhost:${port}/mcp`
|
|
1594
2935
|
});
|
|
1595
2936
|
});
|
|
1596
|
-
//
|
|
2937
|
+
// View app — one page, Chat + Graph sections, fully offline (no CDN dependencies). Never
|
|
2938
|
+
// cached: this shell (and the app JS below) can change between server restarts during
|
|
2939
|
+
// development, and a stale cached copy paired with a fresh one would break silently.
|
|
1597
2940
|
app.get('/', (_req, res) => {
|
|
1598
2941
|
res.setHeader('Content-Type', 'text/html');
|
|
1599
|
-
res.
|
|
2942
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2943
|
+
res.send((0, visualizer_1.getViewHtml)());
|
|
1600
2944
|
});
|
|
1601
|
-
//
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
2945
|
+
// Static app JS/CSS, served from ASSETS_DIR (src/mcp under tsx dev, dist/mcp once built).
|
|
2946
|
+
// Whitelisted by exact basename — this is the only thing these two routes ever read off disk.
|
|
2947
|
+
const APP_FILES = {
|
|
2948
|
+
'view.css': 'text/css; charset=utf-8',
|
|
2949
|
+
'view.js': 'application/javascript; charset=utf-8',
|
|
2950
|
+
'view_chat.js': 'application/javascript; charset=utf-8',
|
|
2951
|
+
'view_graph.js': 'application/javascript; charset=utf-8'
|
|
2952
|
+
};
|
|
2953
|
+
app.get('/app/:file', (req, res) => {
|
|
2954
|
+
const contentType = APP_FILES[req.params.file];
|
|
2955
|
+
if (!contentType)
|
|
2956
|
+
return res.status(404).end();
|
|
2957
|
+
res.setHeader('Content-Type', contentType);
|
|
2958
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2959
|
+
res.sendFile(path.join(visualizer_1.ASSETS_DIR, req.params.file));
|
|
1605
2960
|
});
|
|
1606
|
-
//
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
</head>
|
|
1615
|
-
<body>
|
|
1616
|
-
<div id="3d-graph"></div>
|
|
1617
|
-
<script>
|
|
1618
|
-
const graph = ForceGraph3D()(document.getElementById('3d-graph'));
|
|
1619
|
-
console.log("ForceGraph3D keys:", Object.keys(ForceGraph3D));
|
|
1620
|
-
console.log("Graph instance keys:", Object.keys(graph));
|
|
1621
|
-
console.log("Graph scene constructor:", graph.scene().constructor.name);
|
|
1622
|
-
console.log("window.THREE exists:", typeof window.THREE);
|
|
1623
|
-
</script>
|
|
1624
|
-
</body>
|
|
1625
|
-
</html>`);
|
|
2961
|
+
// Vendored graph libraries — committed, served locally so the view works with no internet.
|
|
2962
|
+
const VENDOR_FILES = new Set(['three.min.js', '3d-force-graph.min.js', 'force-graph.min.js']);
|
|
2963
|
+
app.get('/vendor/:file', (req, res) => {
|
|
2964
|
+
if (!VENDOR_FILES.has(req.params.file))
|
|
2965
|
+
return res.status(404).end();
|
|
2966
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
2967
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
2968
|
+
res.sendFile(path.join(visualizer_1.ASSETS_DIR, 'vendor', req.params.file));
|
|
1626
2969
|
});
|
|
1627
2970
|
// Graph Data API endpoint
|
|
1628
2971
|
app.get('/api/graph-data', (req, res) => {
|
|
@@ -1634,7 +2977,13 @@ async function runHttpMcpServer(port = exports.DEVSMIND_PORT) {
|
|
|
1634
2977
|
const db = getDatabase(devmindPath);
|
|
1635
2978
|
const nodes = db.getAllNodes();
|
|
1636
2979
|
const connections = db.getAllConnections();
|
|
1637
|
-
|
|
2980
|
+
// This payload already carries a full snapshot per entry for the whole repo; the edit trail
|
|
2981
|
+
// holds a before AND an after per edit on top of that, so it is summarized to a count here
|
|
2982
|
+
// and fetched per entry from /api/node-diff only when someone actually opens one.
|
|
2983
|
+
const history = db.getAllHistory().map(h => {
|
|
2984
|
+
const { edits, ...rest } = h;
|
|
2985
|
+
return { ...rest, edit_count: edits.length };
|
|
2986
|
+
});
|
|
1638
2987
|
res.json({ nodes, connections, history });
|
|
1639
2988
|
}
|
|
1640
2989
|
catch (err) {
|
|
@@ -1642,6 +2991,373 @@ async function runHttpMcpServer(port = exports.DEVSMIND_PORT) {
|
|
|
1642
2991
|
res.status(500).json({ error: err.message });
|
|
1643
2992
|
}
|
|
1644
2993
|
});
|
|
2994
|
+
// Per-edit diffs for one history entry.
|
|
2995
|
+
app.get('/api/node-diff', (req, res) => {
|
|
2996
|
+
try {
|
|
2997
|
+
const devmindPath = req.query.path ? String(req.query.path) : path.join(process.cwd(), '.devmind');
|
|
2998
|
+
const historyId = req.query.history_id ? String(req.query.history_id) : '';
|
|
2999
|
+
if (!historyId)
|
|
3000
|
+
return res.status(400).json({ error: 'history_id is required' });
|
|
3001
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3002
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3003
|
+
}
|
|
3004
|
+
const db = getDatabase(devmindPath);
|
|
3005
|
+
const entry = db.getHistoryEntry(historyId);
|
|
3006
|
+
if (!entry)
|
|
3007
|
+
return res.status(404).json({ error: 'history entry not found' });
|
|
3008
|
+
const { revertableIndex, blockedReason } = revertability(db, entry);
|
|
3009
|
+
res.json({
|
|
3010
|
+
history_id: entry.id,
|
|
3011
|
+
node_id: entry.node_id,
|
|
3012
|
+
edits: (0, diff_1.diffEdits)(entry.edits, { revertableIndex, blockedReason })
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
catch (err) {
|
|
3016
|
+
console.error('[DevsMind API Error]:', err);
|
|
3017
|
+
res.status(500).json({ error: err.message });
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
// The only route that writes to source files. Loopback binding keeps the network out, but not
|
|
3021
|
+
// another site in this browser — hence the token, which only a page this server rendered has.
|
|
3022
|
+
app.post('/api/revert', (req, res) => {
|
|
3023
|
+
try {
|
|
3024
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN) {
|
|
3025
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3026
|
+
}
|
|
3027
|
+
const origin = req.get('Origin');
|
|
3028
|
+
if (origin && !isLocalOrigin(origin, port)) {
|
|
3029
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3030
|
+
}
|
|
3031
|
+
if (!req.is('application/json')) {
|
|
3032
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3033
|
+
}
|
|
3034
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3035
|
+
const historyId = req.body?.history_id ? String(req.body.history_id) : '';
|
|
3036
|
+
if (!historyId)
|
|
3037
|
+
return res.status(400).json({ error: 'history_id is required' });
|
|
3038
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3039
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3040
|
+
}
|
|
3041
|
+
const db = getDatabase(devmindPath);
|
|
3042
|
+
const entry = db.getHistoryEntry(historyId);
|
|
3043
|
+
if (!entry)
|
|
3044
|
+
return res.status(404).json({ error: 'history entry not found' });
|
|
3045
|
+
const result = (0, revert_1.revertLastEdit)(db, devmindPath, entry.node_id, historyId);
|
|
3046
|
+
if (!result.ok)
|
|
3047
|
+
return res.status(409).json({ ok: false, error: result.error });
|
|
3048
|
+
res.json(result);
|
|
3049
|
+
}
|
|
3050
|
+
catch (err) {
|
|
3051
|
+
console.error('[DevsMind API Error]:', err);
|
|
3052
|
+
res.status(500).json({ error: err.message });
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
3055
|
+
// Local activity timeline — sessions/messages, read from .devmind/local/ (gitignored, never
|
|
3056
|
+
// committed). Diffs are NOT shipped here (see /api/message-diff), same reasoning as
|
|
3057
|
+
// /api/graph-data: this is one developer's whole history, fetched on every page load.
|
|
3058
|
+
app.get('/api/activity', (req, res) => {
|
|
3059
|
+
try {
|
|
3060
|
+
const devmindPath = req.query.path ? String(req.query.path) : path.join(process.cwd(), '.devmind');
|
|
3061
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3062
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3063
|
+
}
|
|
3064
|
+
const db = getDatabase(devmindPath);
|
|
3065
|
+
const allMessages = (0, activity_1.listMessages)(devmindPath);
|
|
3066
|
+
// Ascending, so index position doubles as "how many messages come after this one" — the
|
|
3067
|
+
// basis for both `can_unrevert` (must be the OLDEST reverted one) and `later_applied_count`
|
|
3068
|
+
// (the revert cascade preview), matching the rules message-revert.ts enforces server-side.
|
|
3069
|
+
const ascending = allMessages.slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
3070
|
+
const oldestReverted = ascending.find(m => m.status === 'reverted');
|
|
3071
|
+
const messageView = (m) => {
|
|
3072
|
+
const idx = ascending.findIndex(x => x.id === m.id);
|
|
3073
|
+
// Not-fully-reverted later messages — what a revert of THIS message would also cascade
|
|
3074
|
+
// into (revertMessage sweeps up 'applied' and 'partial' alike, only 'reverted' is skipped).
|
|
3075
|
+
const laterApplied = ascending.slice(idx + 1).filter(x => x.status !== 'reverted').length;
|
|
3076
|
+
return {
|
|
3077
|
+
id: m.id,
|
|
3078
|
+
session_id: m.session_id,
|
|
3079
|
+
request: m.request,
|
|
3080
|
+
summary: m.summary,
|
|
3081
|
+
status: m.status,
|
|
3082
|
+
created_at: m.created_at,
|
|
3083
|
+
updated_at: m.updated_at,
|
|
3084
|
+
edit_count: m.edits.length,
|
|
3085
|
+
reverted_edit_count: m.edits.filter(e => e.reverted).length,
|
|
3086
|
+
node_ids: m.edits.map(e => e.node_id),
|
|
3087
|
+
// A fully-reverted message can only un-revert if it's the oldest one in that state
|
|
3088
|
+
// (the stack's ordering rule); a `partial` message was touched by a surgical file/edit
|
|
3089
|
+
// revert instead, so it can un-revert independently, no ordering constraint.
|
|
3090
|
+
can_unrevert: (m.status === 'reverted' && oldestReverted?.id === m.id) || m.status === 'partial',
|
|
3091
|
+
later_applied_count: m.status !== 'reverted' ? laterApplied : undefined
|
|
3092
|
+
};
|
|
3093
|
+
};
|
|
3094
|
+
const byId = new Map(allMessages.map(m => [m.id, m]));
|
|
3095
|
+
const sessions = (0, activity_1.readSessions)(devmindPath)
|
|
3096
|
+
.map(s => ({
|
|
3097
|
+
id: s.id,
|
|
3098
|
+
label: s.label ?? null,
|
|
3099
|
+
started_at: s.started_at,
|
|
3100
|
+
last_active: s.last_active,
|
|
3101
|
+
messages: s.message_ids.map(id => byId.get(id)).filter((m) => !!m).map(messageView)
|
|
3102
|
+
}))
|
|
3103
|
+
.filter(s => s.messages.length > 0);
|
|
3104
|
+
res.json({ developer: db.getDeveloperName(), sessions });
|
|
3105
|
+
}
|
|
3106
|
+
catch (err) {
|
|
3107
|
+
console.error('[DevsMind API Error]:', err);
|
|
3108
|
+
res.status(500).json({ error: err.message });
|
|
3109
|
+
}
|
|
3110
|
+
});
|
|
3111
|
+
// Per-edit diffs for one message, fetched only when a human expands it in the Activity page.
|
|
3112
|
+
app.get('/api/message-diff', (req, res) => {
|
|
3113
|
+
try {
|
|
3114
|
+
const devmindPath = req.query.path ? String(req.query.path) : path.join(process.cwd(), '.devmind');
|
|
3115
|
+
const messageId = req.query.message_id ? String(req.query.message_id) : '';
|
|
3116
|
+
if (!messageId)
|
|
3117
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3118
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3119
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3120
|
+
}
|
|
3121
|
+
const message = (0, activity_1.readMessage)(devmindPath, messageId);
|
|
3122
|
+
if (!message)
|
|
3123
|
+
return res.status(404).json({ error: 'message not found' });
|
|
3124
|
+
res.json({
|
|
3125
|
+
message_id: message.id,
|
|
3126
|
+
edits: message.edits.map(e => ({
|
|
3127
|
+
node_id: e.node_id,
|
|
3128
|
+
file_path: e.file_path,
|
|
3129
|
+
lines: (0, diff_1.diffSnapshots)(e.before, e.after)
|
|
3130
|
+
}))
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
catch (err) {
|
|
3134
|
+
console.error('[DevsMind API Error]:', err);
|
|
3135
|
+
res.status(500).json({ error: err.message });
|
|
3136
|
+
}
|
|
3137
|
+
});
|
|
3138
|
+
// Whole-file, git-style diff for a message — collapses every edit the message made to one file
|
|
3139
|
+
// into a single diff, even when several functions in that file changed. Reconstructs the
|
|
3140
|
+
// pre-message file in memory (never writes to disk); a drifted file (hand-edited since, or a
|
|
3141
|
+
// later message touched the same text) falls back to the per-node diffs /api/message-diff
|
|
3142
|
+
// already ships, so the message still renders something rather than a guessed-at diff.
|
|
3143
|
+
app.get('/api/message-file-diff', (req, res) => {
|
|
3144
|
+
try {
|
|
3145
|
+
const devmindPath = req.query.path ? String(req.query.path) : path.join(process.cwd(), '.devmind');
|
|
3146
|
+
const messageId = req.query.message_id ? String(req.query.message_id) : '';
|
|
3147
|
+
if (!messageId)
|
|
3148
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3149
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3150
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3151
|
+
}
|
|
3152
|
+
const message = (0, activity_1.readMessage)(devmindPath, messageId);
|
|
3153
|
+
if (!message)
|
|
3154
|
+
return res.status(404).json({ error: 'message not found' });
|
|
3155
|
+
const byFile = new Map();
|
|
3156
|
+
for (const e of message.edits) {
|
|
3157
|
+
const arr = byFile.get(e.file_path) || [];
|
|
3158
|
+
arr.push(e);
|
|
3159
|
+
byFile.set(e.file_path, arr);
|
|
3160
|
+
}
|
|
3161
|
+
// Per-edit metadata (id, node_id, at, reverted, a mini diff) so the client can offer
|
|
3162
|
+
// whole-file AND single-edit revert/un-revert buttons from this one lazy-loaded call,
|
|
3163
|
+
// without a separate round trip per edit. `lines` is computed here (not shipped as raw
|
|
3164
|
+
// before/after) since the diff algorithm itself only exists server-side.
|
|
3165
|
+
const editMeta = (edits) => edits.map(e => ({ id: e.id, node_id: e.node_id, at: e.at, reverted: !!e.reverted, lines: (0, diff_1.diffSnapshots)(e.before, e.after) }));
|
|
3166
|
+
const files = Array.from(byFile.entries()).map(([filePath, edits]) => {
|
|
3167
|
+
const result = (0, file_diff_1.fileDiffForMessage)(filePath, edits);
|
|
3168
|
+
if (result.drifted) {
|
|
3169
|
+
return {
|
|
3170
|
+
file_path: filePath,
|
|
3171
|
+
drifted: true,
|
|
3172
|
+
drift_reason: result.drift_reason,
|
|
3173
|
+
per_node: edits.map(e => ({ node_id: e.node_id, lines: (0, diff_1.diffSnapshots)(e.before, e.after) })),
|
|
3174
|
+
edits: editMeta(edits)
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
return {
|
|
3178
|
+
file_path: filePath,
|
|
3179
|
+
drifted: false,
|
|
3180
|
+
// `hunks` is trimmed to a few lines of context per change (the PR view); `full_hunks`
|
|
3181
|
+
// re-runs the same diff with no trimming, so the client's "view full file" toggle has
|
|
3182
|
+
// every line of the file, changed lines already tagged, with no second diff algorithm
|
|
3183
|
+
// needed on the client.
|
|
3184
|
+
hunks: result.hunks,
|
|
3185
|
+
full_hunks: (0, diff_1.diffSnapshots)(result.before_file, result.after_file, 1_000_000),
|
|
3186
|
+
before_file: result.before_file,
|
|
3187
|
+
after_file: result.after_file,
|
|
3188
|
+
edits: editMeta(edits)
|
|
3189
|
+
};
|
|
3190
|
+
});
|
|
3191
|
+
res.json({ message_id: message.id, files });
|
|
3192
|
+
}
|
|
3193
|
+
catch (err) {
|
|
3194
|
+
console.error('[DevsMind API Error]:', err);
|
|
3195
|
+
res.status(500).json({ error: err.message });
|
|
3196
|
+
}
|
|
3197
|
+
});
|
|
3198
|
+
// Message-level revert/un-revert — same write-route threat model as /api/revert (source files
|
|
3199
|
+
// change on disk), so the same token + Origin gate applies.
|
|
3200
|
+
app.post('/api/message-revert', (req, res) => {
|
|
3201
|
+
try {
|
|
3202
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN) {
|
|
3203
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3204
|
+
}
|
|
3205
|
+
const origin = req.get('Origin');
|
|
3206
|
+
if (origin && !isLocalOrigin(origin, port)) {
|
|
3207
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3208
|
+
}
|
|
3209
|
+
if (!req.is('application/json')) {
|
|
3210
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3211
|
+
}
|
|
3212
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3213
|
+
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3214
|
+
if (!messageId)
|
|
3215
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3216
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3217
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3218
|
+
}
|
|
3219
|
+
const result = (0, message_revert_1.revertMessage)(devmindPath, messageId);
|
|
3220
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3221
|
+
}
|
|
3222
|
+
catch (err) {
|
|
3223
|
+
console.error('[DevsMind API Error]:', err);
|
|
3224
|
+
res.status(500).json({ error: err.message });
|
|
3225
|
+
}
|
|
3226
|
+
});
|
|
3227
|
+
app.post('/api/message-unrevert', (req, res) => {
|
|
3228
|
+
try {
|
|
3229
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN) {
|
|
3230
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3231
|
+
}
|
|
3232
|
+
const origin = req.get('Origin');
|
|
3233
|
+
if (origin && !isLocalOrigin(origin, port)) {
|
|
3234
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3235
|
+
}
|
|
3236
|
+
if (!req.is('application/json')) {
|
|
3237
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3238
|
+
}
|
|
3239
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3240
|
+
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3241
|
+
if (!messageId)
|
|
3242
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3243
|
+
if (!fs.existsSync(devmindPath)) {
|
|
3244
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3245
|
+
}
|
|
3246
|
+
const result = (0, message_revert_1.unrevertMessage)(devmindPath, messageId);
|
|
3247
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3248
|
+
}
|
|
3249
|
+
catch (err) {
|
|
3250
|
+
console.error('[DevsMind API Error]:', err);
|
|
3251
|
+
res.status(500).json({ error: err.message });
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
3254
|
+
// Finer-grained revert/un-revert — same write-route threat model as the whole-message routes
|
|
3255
|
+
// above, so the same token + Origin gate applies. These target one file's edits within a
|
|
3256
|
+
// message, or one single edit, rather than the whole message.
|
|
3257
|
+
app.post('/api/message-file-revert', (req, res) => {
|
|
3258
|
+
try {
|
|
3259
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN)
|
|
3260
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3261
|
+
const origin = req.get('Origin');
|
|
3262
|
+
if (origin && !isLocalOrigin(origin, port))
|
|
3263
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3264
|
+
if (!req.is('application/json'))
|
|
3265
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3266
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3267
|
+
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3268
|
+
const filePath = req.body?.file_path ? String(req.body.file_path) : '';
|
|
3269
|
+
if (!messageId)
|
|
3270
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3271
|
+
if (!filePath)
|
|
3272
|
+
return res.status(400).json({ error: 'file_path is required' });
|
|
3273
|
+
if (!fs.existsSync(devmindPath))
|
|
3274
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3275
|
+
const result = (0, message_revert_1.revertMessageFile)(devmindPath, messageId, filePath);
|
|
3276
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3277
|
+
}
|
|
3278
|
+
catch (err) {
|
|
3279
|
+
console.error('[DevsMind API Error]:', err);
|
|
3280
|
+
res.status(500).json({ error: err.message });
|
|
3281
|
+
}
|
|
3282
|
+
});
|
|
3283
|
+
app.post('/api/message-file-unrevert', (req, res) => {
|
|
3284
|
+
try {
|
|
3285
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN)
|
|
3286
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3287
|
+
const origin = req.get('Origin');
|
|
3288
|
+
if (origin && !isLocalOrigin(origin, port))
|
|
3289
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3290
|
+
if (!req.is('application/json'))
|
|
3291
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3292
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3293
|
+
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3294
|
+
const filePath = req.body?.file_path ? String(req.body.file_path) : '';
|
|
3295
|
+
if (!messageId)
|
|
3296
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3297
|
+
if (!filePath)
|
|
3298
|
+
return res.status(400).json({ error: 'file_path is required' });
|
|
3299
|
+
if (!fs.existsSync(devmindPath))
|
|
3300
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3301
|
+
const result = (0, message_revert_1.unrevertMessageFile)(devmindPath, messageId, filePath);
|
|
3302
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3303
|
+
}
|
|
3304
|
+
catch (err) {
|
|
3305
|
+
console.error('[DevsMind API Error]:', err);
|
|
3306
|
+
res.status(500).json({ error: err.message });
|
|
3307
|
+
}
|
|
3308
|
+
});
|
|
3309
|
+
app.post('/api/message-edit-revert', (req, res) => {
|
|
3310
|
+
try {
|
|
3311
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN)
|
|
3312
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3313
|
+
const origin = req.get('Origin');
|
|
3314
|
+
if (origin && !isLocalOrigin(origin, port))
|
|
3315
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3316
|
+
if (!req.is('application/json'))
|
|
3317
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3318
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3319
|
+
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3320
|
+
const editId = req.body?.edit_id ? String(req.body.edit_id) : '';
|
|
3321
|
+
if (!messageId)
|
|
3322
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3323
|
+
if (!editId)
|
|
3324
|
+
return res.status(400).json({ error: 'edit_id is required' });
|
|
3325
|
+
if (!fs.existsSync(devmindPath))
|
|
3326
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3327
|
+
const result = (0, message_revert_1.revertMessageEdit)(devmindPath, messageId, editId);
|
|
3328
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3329
|
+
}
|
|
3330
|
+
catch (err) {
|
|
3331
|
+
console.error('[DevsMind API Error]:', err);
|
|
3332
|
+
res.status(500).json({ error: err.message });
|
|
3333
|
+
}
|
|
3334
|
+
});
|
|
3335
|
+
app.post('/api/message-edit-unrevert', (req, res) => {
|
|
3336
|
+
try {
|
|
3337
|
+
if (req.get('X-Devsmind-Token') !== visualizer_1.DEVSMIND_TOKEN)
|
|
3338
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3339
|
+
const origin = req.get('Origin');
|
|
3340
|
+
if (origin && !isLocalOrigin(origin, port))
|
|
3341
|
+
return res.status(403).json({ error: 'forbidden' });
|
|
3342
|
+
if (!req.is('application/json'))
|
|
3343
|
+
return res.status(415).json({ error: 'expected application/json' });
|
|
3344
|
+
const devmindPath = req.body?.path ? String(req.body.path) : path.join(process.cwd(), '.devmind');
|
|
3345
|
+
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3346
|
+
const editId = req.body?.edit_id ? String(req.body.edit_id) : '';
|
|
3347
|
+
if (!messageId)
|
|
3348
|
+
return res.status(400).json({ error: 'message_id is required' });
|
|
3349
|
+
if (!editId)
|
|
3350
|
+
return res.status(400).json({ error: 'edit_id is required' });
|
|
3351
|
+
if (!fs.existsSync(devmindPath))
|
|
3352
|
+
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3353
|
+
const result = (0, message_revert_1.unrevertMessageEdit)(devmindPath, messageId, editId);
|
|
3354
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3355
|
+
}
|
|
3356
|
+
catch (err) {
|
|
3357
|
+
console.error('[DevsMind API Error]:', err);
|
|
3358
|
+
res.status(500).json({ error: err.message });
|
|
3359
|
+
}
|
|
3360
|
+
});
|
|
1645
3361
|
// MCP endpoint — stateless: each request gets its own server + transport pair
|
|
1646
3362
|
app.all('/mcp', async (req, res) => {
|
|
1647
3363
|
try {
|
|
@@ -1664,28 +3380,71 @@ async function runHttpMcpServer(port = exports.DEVSMIND_PORT) {
|
|
|
1664
3380
|
}
|
|
1665
3381
|
}
|
|
1666
3382
|
});
|
|
3383
|
+
return app;
|
|
3384
|
+
}
|
|
3385
|
+
/**
|
|
3386
|
+
* Resolves the one project this server will serve and binds to it, so callers never pass a
|
|
3387
|
+
* `devmind_path`. Explicit `devmindPath` (from `devsmind start --path`) wins; otherwise auto-detect
|
|
3388
|
+
* by walking up from the cwd `devsmind start` was run in — the normal case, since you start it
|
|
3389
|
+
* inside your project. Errors on an explicit path that doesn't exist (a typo should fail loudly,
|
|
3390
|
+
* not silently fall back). A missing auto-detect is a soft warning, not fatal: the server still
|
|
3391
|
+
* starts unbound and the legacy per-call `devmind_path` path keeps working, so nothing regresses.
|
|
3392
|
+
*/
|
|
3393
|
+
function bindServerToProject(devmindPath) {
|
|
3394
|
+
const explicit = devmindPath && String(devmindPath).trim();
|
|
3395
|
+
if (explicit) {
|
|
3396
|
+
// Reuse resolveDevmindPath's own existence checks / slash-normalization by resolving BEFORE
|
|
3397
|
+
// binding — but it short-circuits on boundDevmindPath, so bind only after it returns cleanly.
|
|
3398
|
+
const resolved = resolveDevmindPath(explicit);
|
|
3399
|
+
bindDevmindPath(resolved);
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
const autoDetected = findDevmindDir(process.cwd());
|
|
3403
|
+
if (autoDetected) {
|
|
3404
|
+
bindDevmindPath(autoDetected);
|
|
3405
|
+
}
|
|
3406
|
+
else {
|
|
3407
|
+
console.error(`⚠️ DevsMind: no .devmind directory found from ${process.cwd()} — starting UNBOUND. ` +
|
|
3408
|
+
`Callers must pass devmind_path, or run 'devsmind start' from inside your project (or pass --path).`);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
async function runHttpMcpServer(port = exports.DEVSMIND_PORT, devmindPath) {
|
|
3412
|
+
bindServerToProject(devmindPath);
|
|
3413
|
+
const app = createHttpApp(port);
|
|
1667
3414
|
const httpServer = http.createServer(app);
|
|
1668
3415
|
await new Promise((resolve, reject) => {
|
|
1669
3416
|
httpServer.listen(port, '127.0.0.1', () => resolve());
|
|
1670
3417
|
httpServer.once('error', reject);
|
|
1671
3418
|
});
|
|
1672
3419
|
console.log(`🧠 DevsMind running → http://localhost:${port}/mcp`);
|
|
3420
|
+
if (boundDevmindPath)
|
|
3421
|
+
console.log(` serving project: ${boundDevmindPath}`);
|
|
1673
3422
|
console.log(` press Ctrl+C to stop`);
|
|
3423
|
+
// Pre-warm the ONNX embedder off the critical path: loading the model is a one-time ~hundreds
|
|
3424
|
+
// of ms cost that would otherwise land on the FIRST search_nodes call and make it feel slow.
|
|
3425
|
+
// Fire-and-forget — a failure just means search degrades to BM25+grep, which is already handled.
|
|
3426
|
+
prewarmEmbedder();
|
|
1674
3427
|
registerShutdownHandlers(httpServer);
|
|
1675
3428
|
}
|
|
3429
|
+
/** Kicks off ONNX model load in the background so the first search_nodes doesn't pay the cold cost. */
|
|
3430
|
+
function prewarmEmbedder() {
|
|
3431
|
+
(0, embedder_1.isEmbedderAvailable)().catch(() => { });
|
|
3432
|
+
}
|
|
1676
3433
|
// ── Stdio mode — for direct IDE plugin injection ──────────────────────────────
|
|
1677
3434
|
/**
|
|
1678
3435
|
* Start DevsMind as a stdio MCP server.
|
|
1679
3436
|
* Used when an IDE manages the process directly (e.g. Cursor stdio plugin mode).
|
|
1680
3437
|
*/
|
|
1681
|
-
function runStdioMcpServer() {
|
|
3438
|
+
function runStdioMcpServer(devmindPath) {
|
|
1682
3439
|
// NOTE: do NOT write to stdout here — it is the JSON-RPC pipe.
|
|
3440
|
+
bindServerToProject(devmindPath);
|
|
1683
3441
|
const server = createMcpServer();
|
|
1684
3442
|
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
1685
3443
|
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
1686
3444
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
1687
3445
|
server.connect(transport).then(() => {
|
|
1688
|
-
// connected
|
|
3446
|
+
// connected — pre-warm the embedder in the background (safe: it never writes to stdout).
|
|
3447
|
+
prewarmEmbedder();
|
|
1689
3448
|
}).catch((err) => {
|
|
1690
3449
|
console.error(`⌠Stdio connection failed: ${err.message}`);
|
|
1691
3450
|
process.exit(1);
|