devsmind-mcp 4.1.1 → 4.3.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 +66 -17
- package/dist/cli/activity.js +1 -1
- package/dist/cli/activity.js.map +1 -1
- package/dist/cli/add-repo.d.ts +20 -0
- package/dist/cli/add-repo.js +171 -0
- package/dist/cli/add-repo.js.map +1 -0
- package/dist/cli/analyze.js +11 -1
- package/dist/cli/analyze.js.map +1 -1
- package/dist/cli/branch.d.ts +24 -0
- package/dist/cli/branch.js +148 -0
- package/dist/cli/branch.js.map +1 -0
- package/dist/cli/index.js +56 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.js +27 -39
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/integrations/memory-topics.js +1 -1
- package/dist/cli/integrations/memory-topics.js.map +1 -1
- package/dist/cli/integrations/prompt.d.ts +9 -0
- package/dist/cli/integrations/prompt.js +26 -1
- package/dist/cli/integrations/prompt.js.map +1 -1
- package/dist/cli/integrations/registry.d.ts +23 -7
- package/dist/cli/integrations/registry.js +69 -8
- package/dist/cli/integrations/registry.js.map +1 -1
- package/dist/cli/integrations/skill.d.ts +16 -10
- package/dist/cli/integrations/skill.js +108 -22
- package/dist/cli/integrations/skill.js.map +1 -1
- package/dist/cli/llm-client.js +15 -7
- package/dist/cli/llm-client.js.map +1 -1
- package/dist/cli/prune.js +3 -15
- package/dist/cli/prune.js.map +1 -1
- package/dist/cli/rule.js +2 -2
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/runner.d.ts +8 -0
- package/dist/cli/runner.js +12 -5
- package/dist/cli/runner.js.map +1 -1
- package/dist/cli/sync.d.ts +16 -0
- package/dist/cli/sync.js +80 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/view.js +3 -19
- package/dist/cli/view.js.map +1 -1
- package/dist/db/analyze.d.ts +5 -0
- package/dist/db/analyze.js +23 -0
- package/dist/db/analyze.js.map +1 -1
- package/dist/db/database.d.ts +56 -0
- package/dist/db/database.js +124 -6
- package/dist/db/database.js.map +1 -1
- package/dist/db/grep.js +1 -1
- package/dist/db/grep.js.map +1 -1
- package/dist/db/indexer.d.ts +1 -1
- package/dist/db/indexer.js +3 -3
- package/dist/db/indexer.js.map +1 -1
- package/dist/mcp/server.js +359 -60
- package/dist/mcp/server.js.map +1 -1
- package/dist/utils/config.d.ts +76 -6
- package/dist/utils/config.js +110 -12
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/devsmind-branch.d.ts +55 -0
- package/dist/utils/devsmind-branch.js +244 -0
- package/dist/utils/devsmind-branch.js.map +1 -0
- package/dist/utils/scanner.js +1 -1
- package/dist/utils/scanner.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -55,6 +55,8 @@ const http = __importStar(require("http"));
|
|
|
55
55
|
const fs = __importStar(require("fs"));
|
|
56
56
|
const express_1 = __importDefault(require("express"));
|
|
57
57
|
const database_1 = require("../db/database");
|
|
58
|
+
const config_1 = require("../utils/config");
|
|
59
|
+
const devsmind_branch_1 = require("../utils/devsmind-branch");
|
|
58
60
|
const visualizer_1 = require("./visualizer");
|
|
59
61
|
const diff_1 = require("../utils/diff");
|
|
60
62
|
const revert_1 = require("../db/revert");
|
|
@@ -88,23 +90,23 @@ exports.DEVSMIND_PORT = 4513;
|
|
|
88
90
|
*/
|
|
89
91
|
/** MCP prompts/list name for the workflow-contract prompt below — see createMcpServer's prompts handlers. */
|
|
90
92
|
exports.DEVSMIND_PROMPT_NAME = 'devsmind-workflow';
|
|
91
|
-
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."
|
|
92
|
-
|
|
93
|
-
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.
|
|
94
|
-
|
|
95
|
-
Non-negotiable workflow:
|
|
96
|
-
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.
|
|
97
|
-
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.
|
|
98
|
-
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.
|
|
99
|
-
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.
|
|
100
|
-
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.
|
|
101
|
-
6. Write EVERY file with edit_node — .ts, .vue, .css, .json, .xml, .md, anything — and never your editor's own edit/write tools. 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.
|
|
102
|
-
7. If a file already got edited WITHOUT going through edit_node — your own editor's edit/write tool got used by mistake, or you're catching up on work from before this session — call stage_change to recover it: same file_path/old_string/new_string/replace_all/description shape as edit_node, but it traces and stages the change without touching the file, since new_string is already sitting on disk. It fails clearly (new_string not found) if the edit never actually happened, so it can't silently record the wrong thing. Always prefer edit_node going forward; stage_change exists only to catch up after the fact, not as a second way to make an edit.
|
|
103
|
-
8. 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/stage_change call.
|
|
104
|
-
9. 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 or stage_change 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.
|
|
105
|
-
10. commit_changes is NOT git and never touches it. It writes only into DevsMind's own local graph/database (.devmind/) and local activity log — it never runs git add, git commit, git push, or any other git command, and its "commit" is a completely different thing from a git commit despite the shared word. Calling it successfully is not a signal to now go run git yourself: never run git add/git commit/git push on your own initiative just because commit_changes succeeded, or because you finished a task. Your real git commit — staging and committing the actual code changes — is the developer's decision, made separately, only when they explicitly ask for it, exactly like any other git action.
|
|
106
|
-
11. 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.
|
|
107
|
-
12. 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.
|
|
93
|
+
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."
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
Non-negotiable workflow:
|
|
98
|
+
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.
|
|
99
|
+
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.
|
|
100
|
+
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.
|
|
101
|
+
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.
|
|
102
|
+
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.
|
|
103
|
+
6. Write EVERY file with edit_node — .ts, .vue, .css, .json, .xml, .md, anything — and never your editor's own edit/write tools. 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.
|
|
104
|
+
7. If a file already got edited WITHOUT going through edit_node — your own editor's edit/write tool got used by mistake, or you're catching up on work from before this session — call stage_change to recover it: same file_path/old_string/new_string/replace_all/description shape as edit_node, but it traces and stages the change without touching the file, since new_string is already sitting on disk. It fails clearly (new_string not found) if the edit never actually happened, so it can't silently record the wrong thing. Always prefer edit_node going forward; stage_change exists only to catch up after the fact, not as a second way to make an edit.
|
|
105
|
+
8. 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/stage_change call.
|
|
106
|
+
9. 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 or stage_change 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.
|
|
107
|
+
10. commit_changes is NOT git and never touches it. It writes only into DevsMind's own local graph/database (.devmind/) and local activity log — it never runs git add, git commit, git push, or any other git command, and its "commit" is a completely different thing from a git commit despite the shared word. Calling it successfully is not a signal to now go run git yourself: never run git add/git commit/git push on your own initiative just because commit_changes succeeded, or because you finished a task. Your real git commit — staging and committing the actual code changes — is the developer's decision, made separately, only when they explicitly ask for it, exactly like any other git action.
|
|
108
|
+
11. 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.
|
|
109
|
+
12. 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.
|
|
108
110
|
13. 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.`;
|
|
109
111
|
// Shared node-type taxonomy description, reused by update_history and add_description.
|
|
110
112
|
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' +
|
|
@@ -162,19 +164,30 @@ function bindDevmindPath(devmindPath) {
|
|
|
162
164
|
function getBoundDevmindPath() {
|
|
163
165
|
return boundDevmindPath;
|
|
164
166
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
167
|
+
/**
|
|
168
|
+
* For a SCOPED indexing session (a caller-provided `scratchpad` filename, e.g. `add_repo`'s
|
|
169
|
+
* dedicated one) Phase 1 must only scan the ONE repo that session is for — otherwise
|
|
170
|
+
* `index_continue` would silently pull in every other repo's files too. Phase 2 (edge
|
|
171
|
+
* resolution, inside `index_complete`) deliberately stays whole-graph even for a scoped
|
|
172
|
+
* session — a newly-added repo's outgoing references need the full node set to resolve
|
|
173
|
+
* against, and an existing repo may gain a new incoming reference INTO it, so narrowing that
|
|
174
|
+
* step would be a correctness regression, not an optimization.
|
|
175
|
+
*
|
|
176
|
+
* The DEFAULT (unscoped) scratchpad's `current_repo` is never used to scope anything here —
|
|
177
|
+
* it's set as an incidental side effect of normal Phase 1 processing (whichever repo the last
|
|
178
|
+
* extracted file happened to be in), not a deliberate scope. Only an explicitly-provided
|
|
179
|
+
* `scratchpadFile` opts into this filtering.
|
|
180
|
+
*/
|
|
181
|
+
function scopedRepoFiles(devmindPath, scratchpadFile, pad) {
|
|
182
|
+
const { repos, total_files } = (0, scanner_1.scanRepoFiles)(devmindPath);
|
|
183
|
+
if (!scratchpadFile || !pad.current_repo) {
|
|
184
|
+
return { files: repos.flatMap(r => r.files), totalFiles: total_files };
|
|
176
185
|
}
|
|
186
|
+
const scoped = repos.filter(r => r.repo_name === pad.current_repo);
|
|
187
|
+
return { files: scoped.flatMap(r => r.files), totalFiles: scoped.reduce((sum, r) => sum + r.files.length, 0) };
|
|
177
188
|
}
|
|
189
|
+
// Walk up from a start directory to find a brain folder (`.devsmind`, or a legacy `.devmind`)
|
|
190
|
+
// containing config.json. Both names are tried at every level — see utils/config.ts.
|
|
178
191
|
// Resolve devmind_path from args, falling back to auto-detect from cwd
|
|
179
192
|
function resolveDevmindPath(rawPath) {
|
|
180
193
|
// Bound (stateful) server: it already knows which brain it serves, resolved once at startup.
|
|
@@ -195,10 +208,10 @@ function resolveDevmindPath(rawPath) {
|
|
|
195
208
|
throw new Error(`devmind_path does not exist: "${resolved}". Make sure you pass the exact DEVMIND_PATH from your workspace rules.`);
|
|
196
209
|
}
|
|
197
210
|
// Not provided — auto-detect from where devsmind start was run
|
|
198
|
-
const autoDetected =
|
|
211
|
+
const autoDetected = (0, config_1.findBrainDir)(process.cwd());
|
|
199
212
|
if (autoDetected)
|
|
200
213
|
return autoDetected;
|
|
201
|
-
throw new Error(`devmind_path was not provided and no .
|
|
214
|
+
throw new Error(`devmind_path was not provided and no .devsmind directory (nor a legacy .devmind one) was found by walking up from: "${process.cwd()}". Pass devmind_path explicitly.`);
|
|
202
215
|
}
|
|
203
216
|
/**
|
|
204
217
|
* A required string argument, or a thrown error naming exactly what's missing.
|
|
@@ -264,6 +277,22 @@ const LIST_NODES_DEFAULT_LIMIT = 100;
|
|
|
264
277
|
/** Max characters of reasoning copied onto a workflow step — a timeline of 200 steps has to stay
|
|
265
278
|
* readable in one response, and the full text is always still on the history rows. */
|
|
266
279
|
const STEP_REASONING_CAP = 2000;
|
|
280
|
+
/** `add_repo`'s dedicated scratchpad filename — separate from the default whole-workspace one so
|
|
281
|
+
* a scoped single-repo indexing session can never collide with (or be mistaken for) it. Passed as
|
|
282
|
+
* `scratchpad` to index_checkpoint/index_continue/index_complete to keep working with it. */
|
|
283
|
+
const ADD_REPO_MCP_SCRATCHPAD = 'add_repo_scratchpad.json';
|
|
284
|
+
/** Max characters PER doc in `workflow_add_step`'s `doc_content` — generous enough for a real spec
|
|
285
|
+
* doc, small enough that one call can't dump a multi-MB blob through the MCP transport. */
|
|
286
|
+
const DOC_CONTENT_CAP = 500_000;
|
|
287
|
+
/** Max number of `doc_content` entries per `workflow_add_step` call — a step records one decision,
|
|
288
|
+
* not a document dump. */
|
|
289
|
+
const DOC_CONTENT_MAX_COUNT = 10;
|
|
290
|
+
/** Max number of `doc_uploads` entries per `workflow_add_step` call — same reasoning as
|
|
291
|
+
* DOC_CONTENT_MAX_COUNT. */
|
|
292
|
+
const DOC_UPLOAD_MAX_COUNT = 10;
|
|
293
|
+
/** Max size PER file copied in via `doc_uploads` — generous for a spec doc or a diagram export,
|
|
294
|
+
* small enough that one call can't copy a huge archive into `.devmind/workflows/`. */
|
|
295
|
+
const DOC_UPLOAD_MAX_BYTES = 20 * 1024 * 1024;
|
|
267
296
|
/**
|
|
268
297
|
* The part of a commit's reasoning worth carrying onto a workflow step: WHY it was done, what it
|
|
269
298
|
* was for, and what was decided.
|
|
@@ -321,6 +350,26 @@ function getDatabase(devmindPath) {
|
|
|
321
350
|
}
|
|
322
351
|
return dbCache.get(dbFile);
|
|
323
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Evicts (closing first, best-effort) the cached `DevMindDatabase` for `devmindPath` — the next
|
|
355
|
+
* `getDatabase` call constructs a fresh one. Needed after `add_repo` writes a new repo into
|
|
356
|
+
* `config.json`: `DevMindDatabase` snapshots `ProjectContext` once, in its constructor
|
|
357
|
+
* (`toRepoRelativePath` reads `this.context.config.repos`, set there and never refreshed), so a
|
|
358
|
+
* cached instance from an earlier tool call in the SAME session (e.g. `start_session`) would
|
|
359
|
+
* otherwise keep producing `../<repo>/...`-style ids for the just-added repo's nodes instead of
|
|
360
|
+
* `{<repo>}/...`, having never seen it in `config.repos`.
|
|
361
|
+
*/
|
|
362
|
+
function invalidateDatabase(devmindPath) {
|
|
363
|
+
const dbFile = path.join(devmindPath, 'brain.db');
|
|
364
|
+
const existing = dbCache.get(dbFile);
|
|
365
|
+
if (existing) {
|
|
366
|
+
try {
|
|
367
|
+
existing.close();
|
|
368
|
+
}
|
|
369
|
+
catch { /* best-effort */ }
|
|
370
|
+
dbCache.delete(dbFile);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
324
373
|
/**
|
|
325
374
|
* Closes every cached DB connection (best-effort) and clears the cache. Normally only reached via
|
|
326
375
|
* the SIGINT/SIGTERM shutdown handlers below; exported so tests can release a fixture's cached
|
|
@@ -372,7 +421,10 @@ const SESSION_EXEMPT_READ_TOOLS = new Set([
|
|
|
372
421
|
// are gone, so they no longer belong here. `workflow_list` is NOT exempt any more either: it
|
|
373
422
|
// reports `bound_workflow_id`, which is a per-session fact — without a session_id it could only
|
|
374
423
|
// ever answer null, which reads as "you are on nothing" rather than "I cannot tell".
|
|
375
|
-
'get_activity_log'
|
|
424
|
+
'get_activity_log',
|
|
425
|
+
// Pulls committed brain state down from git — no session-scoped state involved. push_devsmind_branch
|
|
426
|
+
// is NOT exempt: it's a real write (commit + push), same bar as commit_changes/workflow_add_step.
|
|
427
|
+
'pull_devsmind_branch'
|
|
376
428
|
]);
|
|
377
429
|
/**
|
|
378
430
|
* Creates and wires up a DevsMind MCP Server instance.
|
|
@@ -483,7 +535,8 @@ function createMcpServer() {
|
|
|
483
535
|
inputSchema: {
|
|
484
536
|
type: 'object',
|
|
485
537
|
properties: {
|
|
486
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
538
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
539
|
+
scratchpad: { type: 'string', description: 'Advanced: only pass this if add_repo told you to. Selects a scoped indexing session (e.g. the one add_repo started) instead of the default whole-workspace one.' }
|
|
487
540
|
},
|
|
488
541
|
required: ['devmind_path']
|
|
489
542
|
}
|
|
@@ -494,7 +547,8 @@ function createMcpServer() {
|
|
|
494
547
|
inputSchema: {
|
|
495
548
|
type: 'object',
|
|
496
549
|
properties: {
|
|
497
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
550
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
551
|
+
scratchpad: { type: 'string', description: 'Advanced: only pass this if add_repo told you to. Selects a scoped indexing session (e.g. the one add_repo started) instead of the default whole-workspace one.' }
|
|
498
552
|
},
|
|
499
553
|
required: ['devmind_path']
|
|
500
554
|
}
|
|
@@ -505,7 +559,21 @@ function createMcpServer() {
|
|
|
505
559
|
inputSchema: {
|
|
506
560
|
type: 'object',
|
|
507
561
|
properties: {
|
|
508
|
-
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
562
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
563
|
+
scratchpad: { type: 'string', description: 'Advanced: only pass this if add_repo told you to. Selects a scoped indexing session (e.g. the one add_repo started) instead of the default whole-workspace one.' }
|
|
564
|
+
},
|
|
565
|
+
required: ['devmind_path']
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: 'add_repo',
|
|
570
|
+
description: 'Standalone mode only: add ONE new repo to this brain and index just that repo — the graph-scoping equivalent of index_start, but scoped to a single repo instead of the whole workspace. Registers the repo in config.json/.env, then extracts its structure locally (no LLM) and hands back a first batch of nodes to describe, same as index_start. Continue with index_continue / index_complete exactly as you would for a normal index, but ALWAYS pass the same scratchpad value this call returns — omitting it targets the unrelated whole-workspace session instead. Resumable: if a previous add_repo call is still mid-index, call this again with NO name/path and it tells you which repo and scratchpad to continue with, rather than starting over. Refuses with a clear error in embedded mode (an embedded brain already covers exactly the one repo it lives inside).',
|
|
571
|
+
inputSchema: {
|
|
572
|
+
type: 'object',
|
|
573
|
+
properties: {
|
|
574
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
575
|
+
name: { type: 'string', description: 'New repo name. Omit (along with path) to resume a previous add_repo call that has not finished indexing yet.' },
|
|
576
|
+
path: { type: 'string', description: 'Absolute local path to the repo on this machine. Required together with name on a fresh call.' }
|
|
509
577
|
},
|
|
510
578
|
required: ['devmind_path']
|
|
511
579
|
}
|
|
@@ -987,7 +1055,7 @@ function createMcpServer() {
|
|
|
987
1055
|
},
|
|
988
1056
|
{
|
|
989
1057
|
name: 'workflow_add_step',
|
|
990
|
-
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
|
|
1058
|
+
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 three ways: doc_paths (reference a file that already exists in a configured repo, no copy), doc_uploads (COPY an existing file on disk — any type, PDF/docx/image included — into DevsMind\'s own storage; use this for a doc outside any repo, or one you want to survive its source being deleted), or doc_content (paste text you have in hand but that isn\'t a file at all, or no longer is).',
|
|
991
1059
|
inputSchema: {
|
|
992
1060
|
type: 'object',
|
|
993
1061
|
properties: {
|
|
@@ -996,7 +1064,24 @@ function createMcpServer() {
|
|
|
996
1064
|
summary: { type: 'string', description: 'One line: what was decided or found' },
|
|
997
1065
|
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.' },
|
|
998
1066
|
node_ids: { type: 'array', items: { type: 'string' }, description: 'Optional node ids this step relates to. Leave empty for a pure research/decision step.' },
|
|
999
|
-
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.' }
|
|
1067
|
+
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.' },
|
|
1068
|
+
doc_content: {
|
|
1069
|
+
type: 'array',
|
|
1070
|
+
items: {
|
|
1071
|
+
type: 'object',
|
|
1072
|
+
properties: {
|
|
1073
|
+
name: { type: 'string', description: 'File name for this doc, e.g. "interest-caveat.md" — sanitized before use.' },
|
|
1074
|
+
content: { type: 'string', description: `The doc's full text (max ${DOC_CONTENT_CAP.toLocaleString()} chars).` }
|
|
1075
|
+
},
|
|
1076
|
+
required: ['name', 'content']
|
|
1077
|
+
},
|
|
1078
|
+
description: `Optional: up to ${DOC_CONTENT_MAX_COUNT} docs whose CONTENT (not just a path) should be copied into DevsMind's own storage under .devmind/workflows/. Use this instead of doc_paths when the doc has no on-repo home, or when it must keep existing after its source file is deleted. Content is stored on disk, not inlined into future responses — workflow_get_context returns each artifact's file_path, and you read it from there.`
|
|
1079
|
+
},
|
|
1080
|
+
doc_uploads: {
|
|
1081
|
+
type: 'array',
|
|
1082
|
+
items: { type: 'string' },
|
|
1083
|
+
description: `Optional: up to ${DOC_UPLOAD_MAX_COUNT} absolute paths to files (any type; max ${(DOC_UPLOAD_MAX_BYTES / (1024 * 1024)).toFixed(0)}MB each) to COPY into DevsMind's own storage under .devmind/workflows/ — the actual bytes, not just the path. Unlike doc_paths there's no configured-repo restriction, since the point is capturing a doc that may not live in one (or won't stay on disk at all). Use this — not doc_content — whenever the doc already exists as a file; it's simpler and works for binary files doc_content can't hold as text.`
|
|
1084
|
+
}
|
|
1000
1085
|
},
|
|
1001
1086
|
required: ['devmind_path', 'summary']
|
|
1002
1087
|
}
|
|
@@ -1042,6 +1127,29 @@ function createMcpServer() {
|
|
|
1042
1127
|
},
|
|
1043
1128
|
required: ['devmind_path']
|
|
1044
1129
|
}
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
name: 'push_devsmind_branch',
|
|
1133
|
+
description: `Commit graph/history/vectors/workflows onto the dedicated '${devsmind_branch_1.DEVSMIND_BRANCH}' branch and push it. UNLIKE commit_changes, this DOES run real git commands (commit + push) — but only ever on the '${devsmind_branch_1.DEVSMIND_BRANCH}' branch, via a throwaway worktree, so the developer's actual checked-out branch is never touched, moved, or committed to. This exists so a PR diff shows only real code changes, not the hundreds of graph/history files DevsMind itself writes. Only call this when the developer has asked you to push, not as a routine follow-up to commit_changes. Requires a commit message.`,
|
|
1134
|
+
inputSchema: {
|
|
1135
|
+
type: 'object',
|
|
1136
|
+
properties: {
|
|
1137
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
|
|
1138
|
+
message: { type: 'string', description: `Commit message for this snapshot on the '${devsmind_branch_1.DEVSMIND_BRANCH}' branch.` }
|
|
1139
|
+
},
|
|
1140
|
+
required: ['devmind_path', 'message']
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
name: 'pull_devsmind_branch',
|
|
1145
|
+
description: `Read-only counterpart to push_devsmind_branch: copies graph/history/vectors/workflows down from the '${devsmind_branch_1.DEVSMIND_BRANCH}' branch (remote-tracking ref preferred when a remote exists) into .devsmind/ on disk and re-syncs brain.db. Useful after a teammate has pushed — a plain git pull on the code branch no longer brings this data in once it lives on '${devsmind_branch_1.DEVSMIND_BRANCH}' instead.`,
|
|
1146
|
+
inputSchema: {
|
|
1147
|
+
type: 'object',
|
|
1148
|
+
properties: {
|
|
1149
|
+
devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
|
|
1150
|
+
},
|
|
1151
|
+
required: ['devmind_path']
|
|
1152
|
+
}
|
|
1045
1153
|
}
|
|
1046
1154
|
];
|
|
1047
1155
|
// Every tool except start_session (the one tool that CREATES a session) requires
|
|
@@ -1438,7 +1546,8 @@ function createMcpServer() {
|
|
|
1438
1546
|
}
|
|
1439
1547
|
case 'index_checkpoint': {
|
|
1440
1548
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1441
|
-
const
|
|
1549
|
+
const scratchpadFile = args.scratchpad ? String(args.scratchpad) : undefined;
|
|
1550
|
+
const pad = (0, indexer_1.readScratchpad)(devmindPath, scratchpadFile);
|
|
1442
1551
|
if (!pad) {
|
|
1443
1552
|
return {
|
|
1444
1553
|
content: [{ type: 'text', text: JSON.stringify({ error: 'No indexing session found. Call index_start first.' }) }]
|
|
@@ -1465,7 +1574,8 @@ function createMcpServer() {
|
|
|
1465
1574
|
case 'index_continue': {
|
|
1466
1575
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1467
1576
|
const db = getDatabase(devmindPath);
|
|
1468
|
-
const
|
|
1577
|
+
const scratchpadFile = args.scratchpad ? String(args.scratchpad) : undefined;
|
|
1578
|
+
const pad = (0, indexer_1.readScratchpad)(devmindPath, scratchpadFile);
|
|
1469
1579
|
if (!pad) {
|
|
1470
1580
|
return {
|
|
1471
1581
|
content: [{ type: 'text', text: JSON.stringify({ error: 'No indexing session found. Call index_start first.' }) }]
|
|
@@ -1486,8 +1596,7 @@ function createMcpServer() {
|
|
|
1486
1596
|
'NEVER write or execute external scripts to index files — the server already extracted the structure; you only describe.'
|
|
1487
1597
|
];
|
|
1488
1598
|
if (pad.phase === 1) {
|
|
1489
|
-
const {
|
|
1490
|
-
const allFiles = repos.flatMap(r => r.files);
|
|
1599
|
+
const { files: allFiles } = scopedRepoFiles(devmindPath, scratchpadFile, pad);
|
|
1491
1600
|
const startIdx = pad.last_file_indexed ? allFiles.findIndex(f => f === pad.last_file_indexed) + 1 : 0;
|
|
1492
1601
|
const remainingFiles = allFiles.slice(startIdx);
|
|
1493
1602
|
if (remainingFiles.length > 0) {
|
|
@@ -1495,7 +1604,7 @@ function createMcpServer() {
|
|
|
1495
1604
|
pad.files_done += batch.filesExtracted.length;
|
|
1496
1605
|
pad.last_file_indexed = batch.cursor;
|
|
1497
1606
|
pad.nodes_created += batch.nodesCreated;
|
|
1498
|
-
(0, indexer_1.writeScratchpad)(devmindPath, pad);
|
|
1607
|
+
(0, indexer_1.writeScratchpad)(devmindPath, pad, scratchpadFile);
|
|
1499
1608
|
return {
|
|
1500
1609
|
content: [{
|
|
1501
1610
|
type: 'text',
|
|
@@ -1510,7 +1619,7 @@ function createMcpServer() {
|
|
|
1510
1619
|
};
|
|
1511
1620
|
}
|
|
1512
1621
|
pad.phase = 2;
|
|
1513
|
-
(0, indexer_1.writeScratchpad)(devmindPath, pad);
|
|
1622
|
+
(0, indexer_1.writeScratchpad)(devmindPath, pad, scratchpadFile);
|
|
1514
1623
|
}
|
|
1515
1624
|
return {
|
|
1516
1625
|
content: [{
|
|
@@ -1529,7 +1638,8 @@ function createMcpServer() {
|
|
|
1529
1638
|
case 'index_complete': {
|
|
1530
1639
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1531
1640
|
const db = getDatabase(devmindPath);
|
|
1532
|
-
const
|
|
1641
|
+
const scratchpadFile = args.scratchpad ? String(args.scratchpad) : undefined;
|
|
1642
|
+
const pad = (0, indexer_1.readScratchpad)(devmindPath, scratchpadFile);
|
|
1533
1643
|
if (!pad) {
|
|
1534
1644
|
return {
|
|
1535
1645
|
content: [{ type: 'text', text: JSON.stringify({ error: 'No indexing session found. Call index_start first.' }) }]
|
|
@@ -1540,7 +1650,11 @@ function createMcpServer() {
|
|
|
1540
1650
|
content: [{ type: 'text', text: JSON.stringify({ status: 'complete', message: 'Indexing already completed.', scratchpad: pad }, null, 2) }]
|
|
1541
1651
|
};
|
|
1542
1652
|
}
|
|
1543
|
-
|
|
1653
|
+
// Phase 1 completeness is checked against the SAME scope index_continue used to fill
|
|
1654
|
+
// it — the whole workspace for the default session, just this repo's files for a
|
|
1655
|
+
// scoped one (a scoped scratchpad's files_total never covers every other repo, so
|
|
1656
|
+
// comparing it against the unscoped total would report "incomplete" forever).
|
|
1657
|
+
const { totalFiles: total_files } = scopedRepoFiles(devmindPath, scratchpadFile, pad);
|
|
1544
1658
|
if (pad.phase === 1 && pad.files_done < total_files) {
|
|
1545
1659
|
return {
|
|
1546
1660
|
isError: true,
|
|
@@ -1567,7 +1681,7 @@ function createMcpServer() {
|
|
|
1567
1681
|
}]
|
|
1568
1682
|
};
|
|
1569
1683
|
}
|
|
1570
|
-
const finalPad = (0, indexer_1.completeScratchpad)(devmindPath);
|
|
1684
|
+
const finalPad = (0, indexer_1.completeScratchpad)(devmindPath, scratchpadFile);
|
|
1571
1685
|
db.vacuum();
|
|
1572
1686
|
const undescribedCount = db.getAllNodes().filter(n => !n.deprecated && !n.description).length;
|
|
1573
1687
|
return {
|
|
@@ -1592,6 +1706,94 @@ function createMcpServer() {
|
|
|
1592
1706
|
}]
|
|
1593
1707
|
};
|
|
1594
1708
|
}
|
|
1709
|
+
case 'add_repo': {
|
|
1710
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1711
|
+
const ctx = (0, config_1.loadProjectContext)(devmindPath);
|
|
1712
|
+
if (!(0, config_1.isStandaloneMode)(ctx.config)) {
|
|
1713
|
+
return {
|
|
1714
|
+
isError: true,
|
|
1715
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'add_repo is only available in standalone mode — an embedded brain already covers exactly the one repo it lives inside.' }) }]
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
const existingPad = (0, indexer_1.readScratchpad)(devmindPath, ADD_REPO_MCP_SCRATCHPAD);
|
|
1719
|
+
if (existingPad && existingPad.status === 'in_progress' && existingPad.current_repo) {
|
|
1720
|
+
return {
|
|
1721
|
+
content: [{
|
|
1722
|
+
type: 'text',
|
|
1723
|
+
text: JSON.stringify({
|
|
1724
|
+
status: 'resuming',
|
|
1725
|
+
message: `An add_repo session for "${existingPad.current_repo}" is already in progress. Do NOT call add_repo again for it — call index_continue (or index_checkpoint to see where it stands) with scratchpad: "${ADD_REPO_MCP_SCRATCHPAD}".`,
|
|
1726
|
+
repo_name: existingPad.current_repo,
|
|
1727
|
+
scratchpad: existingPad
|
|
1728
|
+
}, null, 2)
|
|
1729
|
+
}]
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
const name = args.name ? String(args.name).trim() : '';
|
|
1733
|
+
const repoPath = args.path ? String(args.path).trim() : '';
|
|
1734
|
+
if (!name || !repoPath) {
|
|
1735
|
+
return {
|
|
1736
|
+
isError: true,
|
|
1737
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'add_repo: both name and path are required to add a new repo (omit both only to resume a previous in-progress add_repo call — none was found).' }) }]
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
if (ctx.config.repos.some(r => r.name === name)) {
|
|
1741
|
+
return {
|
|
1742
|
+
isError: true,
|
|
1743
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `add_repo: a repo named "${name}" already exists in this brain.` }) }]
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
if (!fs.existsSync(repoPath) || !fs.statSync(repoPath).isDirectory()) {
|
|
1747
|
+
return {
|
|
1748
|
+
isError: true,
|
|
1749
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `add_repo: path does not exist or is not a directory: ${repoPath}` }) }]
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
const pathKey = `REPO_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
1753
|
+
const configPath = path.join(devmindPath, 'config.json');
|
|
1754
|
+
ctx.config.repos.push({ name, path_key: pathKey });
|
|
1755
|
+
fs.writeFileSync(configPath, JSON.stringify(ctx.config, null, 2) + '\n', 'utf-8');
|
|
1756
|
+
const envPath = path.join(devmindPath, '.env');
|
|
1757
|
+
const envLines = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8').split('\n').filter(l => l.trim()) : [];
|
|
1758
|
+
envLines.push(`${pathKey}=${repoPath}`);
|
|
1759
|
+
fs.writeFileSync(envPath, envLines.join('\n') + '\n', 'utf-8');
|
|
1760
|
+
// A DB instance already cached by an earlier call THIS session (e.g. start_session)
|
|
1761
|
+
// would still be working from the config.repos snapshot it took at construction time —
|
|
1762
|
+
// see invalidateDatabase's doc comment. Evict it so node ids for the new repo's files
|
|
1763
|
+
// resolve to `{name}/...`, not a fallback `../name/...` relative path.
|
|
1764
|
+
invalidateDatabase(devmindPath);
|
|
1765
|
+
// index_start's own pattern, scoped to just this one repo.
|
|
1766
|
+
const db = getDatabase(devmindPath);
|
|
1767
|
+
const { repos } = (0, scanner_1.scanRepoFiles)(devmindPath);
|
|
1768
|
+
const scopedRepo = repos.find(r => r.repo_name === name);
|
|
1769
|
+
const allFiles = scopedRepo ? scopedRepo.files : [];
|
|
1770
|
+
const pad = (0, indexer_1.createScratchpad)(devmindPath, allFiles.length, ADD_REPO_MCP_SCRATCHPAD);
|
|
1771
|
+
pad.current_repo = name; // scopedRepoFiles relies on this to keep index_continue/index_complete scoped to just this repo
|
|
1772
|
+
const batch = (0, index_build_1.extractFilesIntoGraph)(db, allFiles);
|
|
1773
|
+
pad.files_done = batch.filesExtracted.length;
|
|
1774
|
+
pad.last_file_indexed = batch.cursor;
|
|
1775
|
+
pad.nodes_created = batch.nodesCreated;
|
|
1776
|
+
(0, indexer_1.writeScratchpad)(devmindPath, pad, ADD_REPO_MCP_SCRATCHPAD);
|
|
1777
|
+
return {
|
|
1778
|
+
content: [{
|
|
1779
|
+
type: 'text',
|
|
1780
|
+
text: JSON.stringify({
|
|
1781
|
+
status: 'started',
|
|
1782
|
+
message: `Repo "${name}" added to config.json/.env. Indexing started (structure only, no LLM) — describe every node in batch.nodes with ONE add_description call, then call index_continue for the next batch.`,
|
|
1783
|
+
repo_name: name,
|
|
1784
|
+
scratchpad: pad,
|
|
1785
|
+
batch: { files: batch.filesExtracted, nodes: batch.nodes, file_imports: batch.fileImports },
|
|
1786
|
+
instructions: [
|
|
1787
|
+
`Every index_continue / index_checkpoint / index_complete call for this repo MUST include scratchpad: "${ADD_REPO_MCP_SCRATCHPAD}" — omitting it targets the unrelated whole-workspace session instead.`,
|
|
1788
|
+
'Describe every node in `batch.nodes` with ONE add_description call.',
|
|
1789
|
+
'Then call index_continue (with the scratchpad param) for the next batch. Repeat until it reports no files remain.',
|
|
1790
|
+
'Once every node is described, call index_complete (with the scratchpad param).',
|
|
1791
|
+
'NEVER write or execute external scripts to index files.'
|
|
1792
|
+
]
|
|
1793
|
+
}, null, 2)
|
|
1794
|
+
}]
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1595
1797
|
case 'edit_node': {
|
|
1596
1798
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
1597
1799
|
const editDb = getDatabase(devmindPath);
|
|
@@ -2768,6 +2970,66 @@ function createMcpServer() {
|
|
|
2768
2970
|
}
|
|
2769
2971
|
docPaths.push(db.toRepoRelativePath(abs));
|
|
2770
2972
|
}
|
|
2973
|
+
// `doc_content` is the copy-in counterpart to doc_paths: for text that has no on-repo
|
|
2974
|
+
// home (or won't stay there), store the bytes in DevsMind itself via the same
|
|
2975
|
+
// workflow_artifacts mechanism workflow_import uses, rather than a path that can dangle.
|
|
2976
|
+
const rawDocContent = Array.isArray(args.doc_content) ? args.doc_content : [];
|
|
2977
|
+
if (rawDocContent.length > DOC_CONTENT_MAX_COUNT) {
|
|
2978
|
+
return {
|
|
2979
|
+
isError: true,
|
|
2980
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_content accepts at most ${DOC_CONTENT_MAX_COUNT} docs per step, got ${rawDocContent.length}.` }) }]
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
const docContent = [];
|
|
2984
|
+
for (const raw of rawDocContent) {
|
|
2985
|
+
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || typeof raw.content !== 'string') {
|
|
2986
|
+
return {
|
|
2987
|
+
isError: true,
|
|
2988
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'Each doc_content entry needs a string `name` and a string `content`.' }) }]
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
if (raw.content.length > DOC_CONTENT_CAP) {
|
|
2992
|
+
return {
|
|
2993
|
+
isError: true,
|
|
2994
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_content entry "${raw.name}" is ${raw.content.length} chars, over the ${DOC_CONTENT_CAP} cap.` }) }]
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
docContent.push({ name: raw.name, content: raw.content });
|
|
2998
|
+
}
|
|
2999
|
+
// `doc_uploads` copies an existing file's bytes in — binary-safe, and no configured-repo
|
|
3000
|
+
// restriction (that restriction exists for doc_paths because a rejected reference is
|
|
3001
|
+
// useless to teammates; a copy has no such dependency on where the source lives).
|
|
3002
|
+
const rawDocUploads = Array.isArray(args.doc_uploads) ? args.doc_uploads.map(String) : [];
|
|
3003
|
+
if (rawDocUploads.length > DOC_UPLOAD_MAX_COUNT) {
|
|
3004
|
+
return {
|
|
3005
|
+
isError: true,
|
|
3006
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_uploads accepts at most ${DOC_UPLOAD_MAX_COUNT} files per step, got ${rawDocUploads.length}.` }) }]
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
const docUploads = [];
|
|
3010
|
+
for (const raw of rawDocUploads) {
|
|
3011
|
+
const abs = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(path.dirname(devmindPath), raw);
|
|
3012
|
+
if (!fs.existsSync(abs)) {
|
|
3013
|
+
return {
|
|
3014
|
+
isError: true,
|
|
3015
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_upload does not exist on disk: ${raw}` }) }]
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
const stat = fs.statSync(abs);
|
|
3019
|
+
if (!stat.isFile()) {
|
|
3020
|
+
return {
|
|
3021
|
+
isError: true,
|
|
3022
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_upload is not a file: ${raw}` }) }]
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
if (stat.size > DOC_UPLOAD_MAX_BYTES) {
|
|
3026
|
+
return {
|
|
3027
|
+
isError: true,
|
|
3028
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `doc_upload "${raw}" is ${stat.size} bytes, over the ${DOC_UPLOAD_MAX_BYTES} cap.` }) }]
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
docUploads.push(abs);
|
|
3032
|
+
}
|
|
2771
3033
|
const step = db.addWorkflowStep(workflowId, {
|
|
2772
3034
|
summary: requireStr(args, 'summary', 'workflow_add_step'),
|
|
2773
3035
|
reasoning: args.reasoning ? String(args.reasoning).slice(0, STEP_REASONING_CAP) : undefined,
|
|
@@ -2775,7 +3037,18 @@ function createMcpServer() {
|
|
|
2775
3037
|
docPaths: docPaths.length ? docPaths : undefined,
|
|
2776
3038
|
sessionId
|
|
2777
3039
|
});
|
|
2778
|
-
|
|
3040
|
+
const artifacts = [
|
|
3041
|
+
...docContent.map(doc => db.addWorkflowArtifact(workflowId, { stepId: step.id, type: 'step_doc', sourceName: doc.name, content: doc.content })),
|
|
3042
|
+
...docUploads.map(sourcePath => db.addWorkflowArtifactFromFile(workflowId, { stepId: step.id, type: 'step_doc', sourcePath }))
|
|
3043
|
+
];
|
|
3044
|
+
return {
|
|
3045
|
+
content: [{
|
|
3046
|
+
type: 'text',
|
|
3047
|
+
// Artifact CONTENT is deliberately not echoed back — same reasoning as
|
|
3048
|
+
// getWorkflowContext (database.ts): the file_path is enough, and content just went in.
|
|
3049
|
+
text: JSON.stringify({ status: 'added', step, artifacts: artifacts.length ? artifacts : undefined }, null, 2)
|
|
3050
|
+
}]
|
|
3051
|
+
};
|
|
2779
3052
|
}
|
|
2780
3053
|
case 'workflow_bind': {
|
|
2781
3054
|
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
@@ -2990,6 +3263,32 @@ function createMcpServer() {
|
|
|
2990
3263
|
const result = (0, workflow_import_1.importWorkflowDocs)(db, args.folder_path ? String(args.folder_path) : undefined, args.file_path ? String(args.file_path) : undefined);
|
|
2991
3264
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
2992
3265
|
}
|
|
3266
|
+
case 'push_devsmind_branch': {
|
|
3267
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
3268
|
+
const message = requireStr(args, 'message', 'push_devsmind_branch');
|
|
3269
|
+
const result = (0, devsmind_branch_1.pushDevsmindBranch)(devmindPath, message);
|
|
3270
|
+
const status = !result.committed && !result.pushed
|
|
3271
|
+
? 'nothing_to_push'
|
|
3272
|
+
: result.pushed
|
|
3273
|
+
? (result.committed ? 'pushed' : 'pushed_prior_commit')
|
|
3274
|
+
: 'committed_local_only';
|
|
3275
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status, ...result }, null, 2) }] };
|
|
3276
|
+
}
|
|
3277
|
+
case 'pull_devsmind_branch': {
|
|
3278
|
+
const devmindPath = resolveDevmindPath(args.devmind_path);
|
|
3279
|
+
const result = (0, devsmind_branch_1.pullDevsmindBranch)(devmindPath);
|
|
3280
|
+
if (!result.found) {
|
|
3281
|
+
return {
|
|
3282
|
+
content: [{
|
|
3283
|
+
type: 'text',
|
|
3284
|
+
text: JSON.stringify({ status: 'not_found', message: `No '${devsmind_branch_1.DEVSMIND_BRANCH}' branch found locally or on the remote yet — run push_devsmind_branch first.`, ...result }, null, 2)
|
|
3285
|
+
}]
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
const db = getDatabase(devmindPath);
|
|
3289
|
+
db.syncFromDisk();
|
|
3290
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'pulled', ...result, counts: db.getCounts() }, null, 2) }] };
|
|
3291
|
+
}
|
|
2993
3292
|
// NOTE: `workflow_pause` and `workflow_resume` are retained as thin aliases for
|
|
2994
3293
|
// `workflow_bind`. Their old meaning — move a single global pointer, pausing whoever else
|
|
2995
3294
|
// held it — no longer exists, and could not be reproduced without reintroducing the bug
|
|
@@ -3198,7 +3497,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3198
3497
|
// Graph Data API endpoint
|
|
3199
3498
|
app.get('/api/graph-data', (req, res) => {
|
|
3200
3499
|
try {
|
|
3201
|
-
const devmindPath = req.query.path ? String(req.query.path) :
|
|
3500
|
+
const devmindPath = req.query.path ? String(req.query.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3202
3501
|
if (!fs.existsSync(devmindPath)) {
|
|
3203
3502
|
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3204
3503
|
}
|
|
@@ -3222,7 +3521,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3222
3521
|
// Per-edit diffs for one history entry.
|
|
3223
3522
|
app.get('/api/node-diff', (req, res) => {
|
|
3224
3523
|
try {
|
|
3225
|
-
const devmindPath = req.query.path ? String(req.query.path) :
|
|
3524
|
+
const devmindPath = req.query.path ? String(req.query.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3226
3525
|
const historyId = req.query.history_id ? String(req.query.history_id) : '';
|
|
3227
3526
|
if (!historyId)
|
|
3228
3527
|
return res.status(400).json({ error: 'history_id is required' });
|
|
@@ -3259,7 +3558,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3259
3558
|
if (!req.is('application/json')) {
|
|
3260
3559
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3261
3560
|
}
|
|
3262
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3561
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3263
3562
|
const historyId = req.body?.history_id ? String(req.body.history_id) : '';
|
|
3264
3563
|
if (!historyId)
|
|
3265
3564
|
return res.status(400).json({ error: 'history_id is required' });
|
|
@@ -3285,7 +3584,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3285
3584
|
// /api/graph-data: this is one developer's whole history, fetched on every page load.
|
|
3286
3585
|
app.get('/api/activity', (req, res) => {
|
|
3287
3586
|
try {
|
|
3288
|
-
const devmindPath = req.query.path ? String(req.query.path) :
|
|
3587
|
+
const devmindPath = req.query.path ? String(req.query.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3289
3588
|
if (!fs.existsSync(devmindPath)) {
|
|
3290
3589
|
return res.status(400).json({ error: `Brain directory not found at: ${devmindPath}` });
|
|
3291
3590
|
}
|
|
@@ -3339,7 +3638,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3339
3638
|
// Per-edit diffs for one message, fetched only when a human expands it in the Activity page.
|
|
3340
3639
|
app.get('/api/message-diff', (req, res) => {
|
|
3341
3640
|
try {
|
|
3342
|
-
const devmindPath = req.query.path ? String(req.query.path) :
|
|
3641
|
+
const devmindPath = req.query.path ? String(req.query.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3343
3642
|
const messageId = req.query.message_id ? String(req.query.message_id) : '';
|
|
3344
3643
|
if (!messageId)
|
|
3345
3644
|
return res.status(400).json({ error: 'message_id is required' });
|
|
@@ -3370,7 +3669,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3370
3669
|
// already ships, so the message still renders something rather than a guessed-at diff.
|
|
3371
3670
|
app.get('/api/message-file-diff', (req, res) => {
|
|
3372
3671
|
try {
|
|
3373
|
-
const devmindPath = req.query.path ? String(req.query.path) :
|
|
3672
|
+
const devmindPath = req.query.path ? String(req.query.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3374
3673
|
const messageId = req.query.message_id ? String(req.query.message_id) : '';
|
|
3375
3674
|
if (!messageId)
|
|
3376
3675
|
return res.status(400).json({ error: 'message_id is required' });
|
|
@@ -3437,7 +3736,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3437
3736
|
if (!req.is('application/json')) {
|
|
3438
3737
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3439
3738
|
}
|
|
3440
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3739
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3441
3740
|
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3442
3741
|
if (!messageId)
|
|
3443
3742
|
return res.status(400).json({ error: 'message_id is required' });
|
|
@@ -3464,7 +3763,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3464
3763
|
if (!req.is('application/json')) {
|
|
3465
3764
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3466
3765
|
}
|
|
3467
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3766
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3468
3767
|
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3469
3768
|
if (!messageId)
|
|
3470
3769
|
return res.status(400).json({ error: 'message_id is required' });
|
|
@@ -3491,7 +3790,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3491
3790
|
return res.status(403).json({ error: 'forbidden' });
|
|
3492
3791
|
if (!req.is('application/json'))
|
|
3493
3792
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3494
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3793
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3495
3794
|
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3496
3795
|
const filePath = req.body?.file_path ? String(req.body.file_path) : '';
|
|
3497
3796
|
if (!messageId)
|
|
@@ -3517,7 +3816,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3517
3816
|
return res.status(403).json({ error: 'forbidden' });
|
|
3518
3817
|
if (!req.is('application/json'))
|
|
3519
3818
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3520
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3819
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3521
3820
|
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3522
3821
|
const filePath = req.body?.file_path ? String(req.body.file_path) : '';
|
|
3523
3822
|
if (!messageId)
|
|
@@ -3543,7 +3842,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3543
3842
|
return res.status(403).json({ error: 'forbidden' });
|
|
3544
3843
|
if (!req.is('application/json'))
|
|
3545
3844
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3546
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3845
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3547
3846
|
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3548
3847
|
const editId = req.body?.edit_id ? String(req.body.edit_id) : '';
|
|
3549
3848
|
if (!messageId)
|
|
@@ -3569,7 +3868,7 @@ function createHttpApp(port = exports.DEVSMIND_PORT) {
|
|
|
3569
3868
|
return res.status(403).json({ error: 'forbidden' });
|
|
3570
3869
|
if (!req.is('application/json'))
|
|
3571
3870
|
return res.status(415).json({ error: 'expected application/json' });
|
|
3572
|
-
const devmindPath = req.body?.path ? String(req.body.path) :
|
|
3871
|
+
const devmindPath = req.body?.path ? String(req.body.path) : (0, config_1.brainDirOrDefault)(process.cwd());
|
|
3573
3872
|
const messageId = req.body?.message_id ? String(req.body.message_id) : '';
|
|
3574
3873
|
const editId = req.body?.edit_id ? String(req.body.edit_id) : '';
|
|
3575
3874
|
if (!messageId)
|
|
@@ -3640,7 +3939,7 @@ function bindServerToProject(devmindPath) {
|
|
|
3640
3939
|
console.error(`DevsMind: bound via --path/DEVSMIND_PATH → serving ${boundDevmindPath}`);
|
|
3641
3940
|
return;
|
|
3642
3941
|
}
|
|
3643
|
-
const autoDetected =
|
|
3942
|
+
const autoDetected = (0, config_1.findBrainDir)(process.cwd());
|
|
3644
3943
|
if (autoDetected) {
|
|
3645
3944
|
bindDevmindPath(autoDetected);
|
|
3646
3945
|
// The one line that makes auto-detect debuggable: a global (no --path) config relies on the
|
|
@@ -3651,7 +3950,7 @@ function bindServerToProject(devmindPath) {
|
|
|
3651
3950
|
console.error(`DevsMind: started in ${process.cwd()} → serving ${boundDevmindPath}`);
|
|
3652
3951
|
}
|
|
3653
3952
|
else {
|
|
3654
|
-
console.error(`⚠️ DevsMind: no .
|
|
3953
|
+
console.error(`⚠️ DevsMind: no .devsmind directory (nor a legacy .devmind one) found from ${process.cwd()} — starting UNBOUND. ` +
|
|
3655
3954
|
`Callers must pass devmind_path, or run 'devsmind start' from inside your project (or pass --path).`);
|
|
3656
3955
|
}
|
|
3657
3956
|
}
|