devsmind-mcp 3.0.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.
Files changed (41) hide show
  1. package/README.md +11 -20
  2. package/dist/cli/diff.js +1 -1
  3. package/dist/cli/diff.js.map +1 -1
  4. package/dist/cli/index.js +15 -8
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/integrations/memory-topics.d.ts +21 -10
  7. package/dist/cli/integrations/memory-topics.js +32 -9
  8. package/dist/cli/integrations/memory-topics.js.map +1 -1
  9. package/dist/cli/integrations/memory.d.ts +14 -9
  10. package/dist/cli/integrations/memory.js +47 -229
  11. package/dist/cli/integrations/memory.js.map +1 -1
  12. package/dist/cli/integrations/registry.d.ts +7 -4
  13. package/dist/cli/integrations/registry.js +39 -18
  14. package/dist/cli/integrations/registry.js.map +1 -1
  15. package/dist/cli/rule.js +11 -16
  16. package/dist/cli/rule.js.map +1 -1
  17. package/dist/db/activity-graph.d.ts +55 -0
  18. package/dist/db/activity-graph.js +314 -0
  19. package/dist/db/activity-graph.js.map +1 -0
  20. package/dist/db/activity.d.ts +21 -0
  21. package/dist/db/activity.js +3 -2
  22. package/dist/db/activity.js.map +1 -1
  23. package/dist/db/database.d.ts +72 -4
  24. package/dist/db/database.js +89 -8
  25. package/dist/db/database.js.map +1 -1
  26. package/dist/db/index-build.d.ts +75 -0
  27. package/dist/db/index-build.js +177 -0
  28. package/dist/db/index-build.js.map +1 -0
  29. package/dist/db/revert.js +1 -1
  30. package/dist/db/revert.js.map +1 -1
  31. package/dist/db/schema.d.ts +2 -2
  32. package/dist/db/staging.d.ts +3 -2
  33. package/dist/db/staging.js +1 -1
  34. package/dist/db/staging.js.map +1 -1
  35. package/dist/mcp/server.d.ts +1 -1
  36. package/dist/mcp/server.js +187 -203
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/utils/scanner.d.ts +6 -4
  39. package/dist/utils/scanner.js +6 -4
  40. package/dist/utils/scanner.js.map +1 -1
  41. package/package.json +1 -1
@@ -59,9 +59,11 @@ const visualizer_1 = require("./visualizer");
59
59
  const diff_1 = require("../utils/diff");
60
60
  const revert_1 = require("../db/revert");
61
61
  const activity_1 = require("../db/activity");
62
+ const activity_graph_1 = require("../db/activity-graph");
62
63
  const message_revert_1 = require("../db/message-revert");
63
64
  const file_diff_1 = require("../db/file-diff");
64
65
  const indexer_1 = require("../db/indexer");
66
+ const index_build_1 = require("../db/index-build");
65
67
  const scanner_1 = require("../utils/scanner");
66
68
  const ast_1 = require("../utils/ast");
67
69
  const edit_1 = require("../utils/edit");
@@ -89,20 +91,18 @@ exports.DEVSMIND_INSTRUCTIONS = `DevsMind is this team's persistent shared code
89
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.
90
92
 
91
93
  Non-negotiable workflow:
92
- 1. Call start_session once, before your first WRITE of the conversation (edit_node/stage_change/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.
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.
93
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.
94
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.
95
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.
96
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.
97
- 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, and no stage_change call. It answers with every caller of what you changed. 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.
98
- 7. stage_change is now only for what no parser can read: a language with no AST support (.py, .go, .java, .cs, .rb, .php, .rs, .swift, .kt, .dart). edit_node still writes those files, and its response tells you when it couldn't trace one so never guess. One call per node, not per file, never batched for later. On a long task, call commit_changes at natural checkpoints too (not only once at the very end) waiting until the whole task is "done" is how staged work gets left uncommitted when a session runs long.
99
- 8. Scope: the graph is source code only (functions/classes/logic). stage_change will be REJECTED for stylesheets, markup, JSON/config, docs, images, or any other non-code asset. Do not stage those filesthey have no callers/callees to resolve and only bloat the graph.
100
- 9. 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.
101
- 10. 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 involvednever 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
- 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.
103
- 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.
104
- 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.`;
105
- // Shared node-type taxonomy description, reused by update_history and stage_change.
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.
106
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' +
107
107
  'UNIVERSAL: function | method | class | abstract_class | interface | type_alias | enum | constant | variable | module | namespace | decorator\n\n' +
108
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' +
@@ -132,7 +132,7 @@ const NODE_TYPE_DESCRIPTION = 'The type of node. Be highly specific and framewor
132
132
  'SCRIPTS: build_script | migration_script | seed_script\n' +
133
133
  'TESTS: test_suite | test_case | test_helper | mock | fixture\n' +
134
134
  'UTILITY: util_function | helper | transformer | validator | formatter';
135
- // Shared `description` field schema, reused by stage_change and add_description. This is what
135
+ // Shared `description` field schema, reused by edit_node and add_description. This is what
136
136
  // makes search_nodes findable by natural language — an identifier alone is a handful of words;
137
137
  // this is where the domain vocabulary (login/auth/sign-in, cart/basket, ...) actually lives.
138
138
  const DESCRIPTION_FIELD_SCHEMA = {
@@ -458,13 +458,13 @@ function createMcpServer() {
458
458
  }
459
459
  },
460
460
  // NOTE: `update_history`, `add_node`, and `add_connection` are intentionally NOT listed
461
- // here. They are deprecated in favour of `stage_change` + `commit_changes` (to avoid
461
+ // here. They are deprecated in favour of `edit_node` + `commit_changes` (to avoid
462
462
  // confusing the AI with overlapping write tools), but their handlers are retained below
463
463
  // so any direct/legacy call still works.
464
464
  // ────────────────── Indexing tools ─────────────────────────────────────────
465
465
  {
466
466
  name: 'index_start',
467
- 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.',
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
468
  inputSchema: {
469
469
  type: 'object',
470
470
  properties: {
@@ -475,28 +475,18 @@ function createMcpServer() {
475
475
  },
476
476
  {
477
477
  name: 'index_checkpoint',
478
- description: 'Save current indexing progress to the scratchpad. Call this every ~10 files so progress survives a context reset.',
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
479
  inputSchema: {
480
480
  type: 'object',
481
481
  properties: {
482
- devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
483
- last_file_indexed: { type: 'string', description: 'Absolute path to the last file that was fully indexed' },
484
- files_done: { type: 'number', description: 'Total files indexed so far' },
485
- nodes_created: { type: 'number', description: 'Total nodes created so far' },
486
- connections_created: { type: 'number', description: 'Total connections created so far' },
487
- current_repo: { type: 'string', description: 'Name of the repo currently being indexed' },
488
- repos_done: {
489
- type: 'array',
490
- items: { type: 'string' },
491
- description: 'Names of repos fully indexed so far'
492
- }
482
+ devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' }
493
483
  },
494
- required: ['devmind_path', 'files_done', 'nodes_created']
484
+ required: ['devmind_path']
495
485
  }
496
486
  },
497
487
  {
498
488
  name: 'index_continue',
499
- 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.',
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.',
500
490
  inputSchema: {
501
491
  type: 'object',
502
492
  properties: {
@@ -507,7 +497,7 @@ function createMcpServer() {
507
497
  },
508
498
  {
509
499
  name: 'index_complete',
510
- description: 'Mark the indexing session as complete. Call this when all files in all repos have been indexed.',
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.',
511
501
  inputSchema: {
512
502
  type: 'object',
513
503
  properties: {
@@ -519,7 +509,7 @@ function createMcpServer() {
519
509
  {
520
510
  name: 'edit_node',
521
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" +
522
- "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, 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" +
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" +
523
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" +
524
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" +
525
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.",
@@ -539,27 +529,9 @@ function createMcpServer() {
539
529
  required: ['devmind_path', 'file_path', 'old_string', 'new_string']
540
530
  }
541
531
  },
542
- {
543
- name: 'stage_change',
544
- description: `Stage ONE changed code node (function/class/method/etc.) into a buffer, right after you finish editing it — don't wait until the whole task is done. Call once per NODE, not once per file: a file with 3 changed functions is 3 calls. SCOPE: source code only — ${Array.from(scanner_1.INDEXABLE_EXTENSIONS).sort().join(', ')}. Rejected for stylesheets, markup, JSON/config, docs, or other non-code assets (no callers/callees to resolve). Pass only the code; connections are resolved automatically by commit_changes, not by you. Staging is buffered on disk and survives a context reset, but is inert until commit_changes runs — that's also where you give the one \`reasoning\` covering everything staged since the last commit.`,
545
- inputSchema: {
546
- type: 'object',
547
- properties: {
548
- devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
549
- node_id: { type: 'string', description: 'Unique identifier for the node (e.g. "CartService.applyPromoCode" or "calculateDiscount")' },
550
- file_path: { type: 'string', description: 'Source file path where the node is located' },
551
- code_snapshot: { type: 'string', description: 'Full source code content of the node at this moment' },
552
- name: { type: 'string', description: 'Display name of the node (optional, inferred if omitted)' },
553
- type: { type: 'string', description: '(optional, defaults to function) ' + NODE_TYPE_DESCRIPTION },
554
- signature: { type: 'string', description: 'Parameter types + return type signature (optional)' },
555
- description: DESCRIPTION_FIELD_SCHEMA
556
- },
557
- required: ['devmind_path', 'node_id', 'file_path', 'code_snapshot']
558
- }
559
- },
560
532
  {
561
533
  name: 'add_description',
562
- 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. Two 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. 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.',
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.',
563
535
  inputSchema: {
564
536
  type: 'object',
565
537
  properties: {
@@ -571,7 +543,8 @@ function createMcpServer() {
571
543
  type: 'object',
572
544
  properties: {
573
545
  node_id: { type: 'string', description: 'The exact node_id, as given in the commit_changes rejection or from search/list_nodes.' },
574
- description: DESCRIPTION_FIELD_SCHEMA
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 }
575
548
  },
576
549
  required: ['node_id', 'description']
577
550
  }
@@ -636,7 +609,7 @@ function createMcpServer() {
636
609
  },
637
610
  {
638
611
  name: 'commit_changes',
639
- description: 'Flush THIS SESSION\'s buffered edit_node/stage_change 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.',
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.',
640
613
  inputSchema: {
641
614
  type: 'object',
642
615
  properties: {
@@ -862,18 +835,23 @@ function createMcpServer() {
862
835
  },
863
836
  {
864
837
  name: 'get_activity_log',
865
- 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). Reads the local activity log (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) and `node_ids`, plus `developer`/`created_at`/`request`/`summary`/`status`. The response also includes `all_files` every distinct file across ALL matched entries, flattened into one list. Local and gitignored: this only ever reflects what happened on THIS machine, never the shared graph.',
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.',
866
839
  inputSchema: {
867
840
  type: 'object',
868
841
  properties: {
869
842
  devmind_path: { type: 'string', description: 'Absolute path to the .devmind directory' },
870
- developer: { type: 'string', description: 'Case-insensitive substring match on developer name/email (e.g. "AbialiDr"). Omit for all developers.' },
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).'
847
+ },
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.' },
871
849
  session_id: { type: 'string', description: 'Restrict to one session id (from start_session).' },
872
850
  since_hours: { type: 'number', description: 'Lookback window in hours — e.g. 48 for "the past 2 days". Ignored if `since` is also given.' },
873
851
  since: { type: 'string', description: 'ISO timestamp lower bound (inclusive). Takes priority over since_hours.' },
874
852
  until: { type: 'string', description: 'ISO timestamp upper bound (inclusive).' },
875
853
  requirement_contains: { type: 'string', description: 'Case-insensitive substring match against the request text or summary — for finding changes tied to a ticket/requirement.' },
876
- limit: { type: 'number', description: 'Maximum entries to return, most recent first (optional, default 100).' }
854
+ limit: { type: 'number', description: 'Maximum entries to return, most recent first (optional, default 100). Check `total_matched` to see how many were dropped.' }
877
855
  },
878
856
  required: ['devmind_path']
879
857
  }
@@ -1391,43 +1369,42 @@ function createMcpServer() {
1391
1369
  // ————————————————————————————————— Indexing tool handlers —————————————————————————————————
1392
1370
  case 'index_start': {
1393
1371
  const devmindPath = resolveDevmindPath(args.devmind_path);
1372
+ const db = getDatabase(devmindPath);
1394
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;
1395
1386
  const pad = (0, indexer_1.createScratchpad)(devmindPath, total_files);
1396
- const repoSummaries = repos.map(r => ({
1397
- repo_name: r.repo_name,
1398
- repo_path: r.repo_path,
1399
- file_count: r.file_count,
1400
- files: r.files // full list so AI can iterate
1401
- }));
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);
1402
1392
  return {
1403
1393
  content: [{
1404
1394
  type: 'text',
1405
1395
  text: JSON.stringify({
1406
- message: 'Indexing session started. Extract nodes with stage_change (one call per entity), then call commit_changes to write them all and resolve connections automatically via AST. Call index_checkpoint every 10 files.',
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.',
1407
1397
  scratchpad: pad,
1408
- repos: repoSummaries,
1398
+ repos: repos.map(r => ({ repo_name: r.repo_name, repo_path: r.repo_path, file_count: r.file_count })),
1409
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 },
1410
1403
  instructions: [
1411
- '⚠️⚠️⚠️ CRITICAL INSTRUCTION FOR THE INDEXING AGENTMUST READ ⚠️⚠️⚠️ ',
1412
- 'YOU MUST EXPLICITLY CALL THE "stage_change" MCP TOOL FOR EVERY ENTITY YOU EXTRACT, THEN "commit_changes" TO WRITE THEM.',
1413
- 'DO NOT JUST PRINT THE RESULTS AS TEXT IN THE CHAT WINDOW. PRINTING RESULTS WITHOUT CALLING THE MCP TOOLS DOES NOT WRITE THEM TO THE DATABASE AND MAKES THE ENTIRE INDEXING RUN A WASTE OF TIME AND TOKENS.',
1414
- 'NEVER WRITE OR EXECUTE EXTERNAL SCRIPTS (like Python, Node.js, Bash, etc.) to automate or lazy load indexing. You must read files and call the MCP tools step-by-step natively in the chat. This ensures progress is tracked in the SQLite scratchpad database and can be resumed/continued in subsequent chats if context limits are hit.',
1415
- '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.',
1416
- 'IF YOU ENCOUNTER CONTEXT RESETS, RESUME WORK BY CALLING "index_continue" AND CONTINUOUSLY COMMIT PROGRESS BY CALLING "index_checkpoint" EVERY 10 FILES.',
1417
- '',
1418
- '📋 CODE EXCLUSION & PRECISION RULES:',
1419
- '1. EXCLUDE Language Globals / Built-ins: Do NOT stage nodes for Promise, Map, Set, JSON, console, Error, Object, Array, RegExp, Date, Math, etc.',
1420
- '2. EXCLUDE Primitive/Native Types: Do NOT stage nodes for string, number, boolean, any, void, unknown, never, null, undefined, dict, list, etc.',
1421
- '3. EXCLUDE External / Third-party Modules: Do NOT stage nodes for lodash, express, react, @nestjs/common, etc.',
1422
- '4. INTERNAL ENTITIES ONLY: Only stage nodes for constructs defined inside this codebase.',
1423
- '',
1424
- '📋 STAGE → COMMIT INDEXING PROTOCOL:',
1425
- '1. For each file in each repo: read it, extract ALL defined nodes — functions, methods, classes, interfaces, types, DTOs, routing handlers, schemas, resolvers, etc.',
1426
- '2. Call stage_change for EVERY entity found — pass its node_id, file_path, code_snapshot, the most specific taxonomy type, AND a `description`: 1-3 sentences of what it actually DOES and the domain concepts involved, using words a developer would search by later — NOT a restatement of the name ("verifyCredentials verifies credentials" is rejected). You are reading the whole file right now, with maximum context — this is the cheapest moment this description will ever be to write; skipping it here means commit_changes will refuse the node later and you will have to come back to it anyway. You do NOT need to figure out connections; commit_changes resolves them from the code via AST.',
1427
- '3. Call index_checkpoint every 10 files to save progress.',
1428
- '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. Pass ONE `reasoning` per commit_changes call describing this batch (e.g. "Initial index of <repo>") — it is required, and applies to every entity in that batch. commit_changes REFUSES any batch containing a new node with no description — if that happens, call add_description with the node_ids it lists, then call commit_changes again; nothing staged is lost by the rejection.',
1429
- '5. When the whole codebase is staged and committed, call index_complete.',
1430
- '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.'
1431
1408
  ]
1432
1409
  }, null, 2)
1433
1410
  }]
@@ -1435,26 +1412,33 @@ function createMcpServer() {
1435
1412
  }
1436
1413
  case 'index_checkpoint': {
1437
1414
  const devmindPath = resolveDevmindPath(args.devmind_path);
1438
- const pad = (0, indexer_1.updateScratchpad)(devmindPath, {
1439
- last_file_indexed: args.last_file_indexed ? String(args.last_file_indexed) : undefined,
1440
- files_done: typeof args.files_done === 'number' ? args.files_done : 0,
1441
- nodes_created: typeof args.nodes_created === 'number' ? args.nodes_created : 0,
1442
- connections_created: typeof args.connections_created === 'number' ? args.connections_created : 0,
1443
- current_repo: args.current_repo ? String(args.current_repo) : undefined,
1444
- repos_done: Array.isArray(args.repos_done) ? args.repos_done : undefined
1445
- });
1446
- const pct = pad.files_total > 0
1447
- ? Math.round((pad.files_done / pad.files_total) * 100)
1448
- : 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;
1449
1425
  return {
1450
1426
  content: [{
1451
1427
  type: 'text',
1452
- text: JSON.stringify({ saved: true, progress: `${pad.files_done}/${pad.files_total} files (${pct}%)`, scratchpad: pad }, null, 2)
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)
1453
1436
  }]
1454
1437
  };
1455
1438
  }
1456
1439
  case 'index_continue': {
1457
1440
  const devmindPath = resolveDevmindPath(args.devmind_path);
1441
+ const db = getDatabase(devmindPath);
1458
1442
  const pad = (0, indexer_1.readScratchpad)(devmindPath);
1459
1443
  if (!pad) {
1460
1444
  return {
@@ -1466,51 +1450,118 @@ function createMcpServer() {
1466
1450
  content: [{ type: 'text', text: JSON.stringify({ status: 'complete', message: 'Indexing already completed.', scratchpad: pad }, null, 2) }]
1467
1451
  };
1468
1452
  }
1469
- // Re-scan to get file lists so AI knows which files are left
1470
- const { repos } = (0, scanner_1.scanRepoFiles)(devmindPath);
1471
- const reposDone = new Set(pad.repos_done);
1472
- const remaining = repos
1473
- .filter(r => !reposDone.has(r.repo_name))
1474
- .map(r => ({ repo_name: r.repo_name, repo_path: r.repo_path, files: r.files, file_count: r.file_count }));
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
+ }
1475
1489
  return {
1476
1490
  content: [{
1477
1491
  type: 'text',
1478
1492
  text: JSON.stringify({
1479
- message: 'Resume indexing from where you left off.',
1493
+ message: 'All files extracted. Describe any remaining nodes, then call index_complete.',
1480
1494
  scratchpad: pad,
1481
- last_file_indexed: pad.last_file_indexed,
1482
- repos_done: pad.repos_done,
1483
- remaining_repos: remaining,
1484
- // Repeated here because a context reset means the original index_start
1485
- // instructions are gone from view — without this, a resumed session silently
1486
- // stops writing descriptions on every node from here on.
1487
- instructions: [
1488
- 'Continue the STAGE → COMMIT INDEXING PROTOCOL exactly as before, for remaining_repos only.',
1489
- 'Every stage_change call still needs a `description`: 1-3 sentences of what the entity does and its domain concepts, not a restatement of its name.',
1490
- 'commit_changes still REFUSES a batch containing a new node with no description — call add_description with the listed node_ids, then retry the commit.',
1491
- 'Call index_checkpoint every 10 files, commit_changes every ~50 entities, index_complete when done, then recheck_graph.'
1492
- ]
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.']
1493
1499
  }, null, 2)
1494
1500
  }]
1495
1501
  };
1496
1502
  }
1497
1503
  case 'index_complete': {
1498
1504
  const devmindPath = resolveDevmindPath(args.devmind_path);
1499
- const pad = (0, indexer_1.completeScratchpad)(devmindPath);
1500
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);
1501
1545
  db.vacuum();
1546
+ const undescribedCount = db.getAllNodes().filter(n => !n.deprecated && !n.description).length;
1502
1547
  return {
1503
1548
  content: [{
1504
1549
  type: 'text',
1505
1550
  text: JSON.stringify({
1506
1551
  message: '✅ Indexing complete! Full graph is now available.',
1507
1552
  summary: {
1508
- files_indexed: pad.files_done,
1509
- nodes_created: pad.nodes_created,
1510
- connections_created: pad.connections_created,
1511
- started_at: pad.started_at,
1512
- completed_at: pad.updated_at
1513
- }
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.'
1514
1565
  }, null, 2)
1515
1566
  }]
1516
1567
  };
@@ -1694,7 +1745,7 @@ function createMcpServer() {
1694
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.';
1695
1746
  }
1696
1747
  else {
1697
- reminder = `${ext} cannot be parsed for symbols, so 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 — call stage_change yourself only if you also want a graph node for it.`;
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.`;
1698
1749
  }
1699
1750
  // Two blocks on purpose: a rendered diff for the human watching the session (clients
1700
1751
  // that highlight markdown colour the ```diff fence; the rest show plain +/- lines), and
@@ -1719,81 +1770,6 @@ function createMcpServer() {
1719
1770
  });
1720
1771
  return { content };
1721
1772
  }
1722
- case 'stage_change': {
1723
- const devmindPath = resolveDevmindPath(args.devmind_path);
1724
- const rawFilePath = requireStr(args, 'file_path', 'stage_change');
1725
- const ext = path.extname(rawFilePath).toLowerCase();
1726
- if (!scanner_1.INDEXABLE_EXTENSIONS.has(ext)) {
1727
- return {
1728
- isError: true,
1729
- content: [{
1730
- type: 'text',
1731
- text: JSON.stringify({
1732
- staged: false,
1733
- error: `'${ext || '(no extension)'}' is not a supported node file type — nothing was staged.`,
1734
- 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.',
1735
- supported_extensions: Array.from(scanner_1.INDEXABLE_EXTENSIONS).sort()
1736
- })
1737
- }]
1738
- };
1739
- }
1740
- const workspaceRoot = path.dirname(devmindPath);
1741
- const filePath = path.isAbsolute(rawFilePath) ? path.resolve(rawFilePath) : path.resolve(workspaceRoot, rawFilePath);
1742
- const stageDb = getDatabase(devmindPath);
1743
- if (!stageDb.isPathAllowed(filePath)) {
1744
- return {
1745
- isError: true,
1746
- content: [{
1747
- type: 'text',
1748
- text: JSON.stringify({
1749
- staged: false,
1750
- error: `file_path resolves outside the project's configured repos — nothing was staged.`,
1751
- reason: 'stage_change only accepts paths inside a repo this project knows about, to prevent staging/reading files outside the project.',
1752
- resolved_path: filePath
1753
- })
1754
- }]
1755
- };
1756
- }
1757
- const stageNodeId = requireStr(args, 'node_id', 'stage_change');
1758
- let stageDescription;
1759
- if (args.description !== undefined) {
1760
- const check = (0, tokenize_1.validateDescription)(String(args.description), (args.name ? String(args.name) : stageNodeId));
1761
- if (!check.ok) {
1762
- return {
1763
- isError: true,
1764
- content: [{ type: 'text', text: JSON.stringify({ staged: false, error: check.error }) }]
1765
- };
1766
- }
1767
- stageDescription = String(args.description);
1768
- }
1769
- const entry = {
1770
- node_id: stageNodeId,
1771
- file_path: filePath,
1772
- code_snapshot: requireStr(args, 'code_snapshot', 'stage_change'),
1773
- name: args.name ? String(args.name) : undefined,
1774
- type: args.type ? String(args.type) : undefined,
1775
- signature: args.signature ? String(args.signature) : undefined,
1776
- description: stageDescription,
1777
- session_id: sessionId
1778
- };
1779
- (0, staging_1.stageEntry)(devmindPath, entry);
1780
- // Scoped to THIS session, not the raw buffer length — see partitionStagedForSession.
1781
- // The shared buffer can also hold another session's unrelated staged work, which must
1782
- // never be counted as "yours to commit".
1783
- const scoped = (0, staging_1.partitionStagedForSession)(devmindPath, sessionId);
1784
- const pendingCount = scoped.entries.length + scoped.fileEdits.length;
1785
- return {
1786
- content: [{
1787
- type: 'text',
1788
- text: JSON.stringify({
1789
- staged: true,
1790
- node_id: entry.node_id,
1791
- pending_count: pendingCount,
1792
- reminder: 'Call commit_changes once you have staged every touched file, or nothing is written to the graph.'
1793
- })
1794
- }]
1795
- };
1796
- }
1797
1773
  case 'add_description': {
1798
1774
  const devmindPath = resolveDevmindPath(args.devmind_path);
1799
1775
  const db = getDatabase(devmindPath);
@@ -1832,6 +1808,8 @@ function createMcpServer() {
1832
1808
  const stagedEntry = staged.find(e => matchesNodeId(e) && (!e.session_id || e.session_id === sessionId));
1833
1809
  if (stagedEntry) {
1834
1810
  stagedEntry.description = description;
1811
+ if (item && item.type)
1812
+ stagedEntry.type = String(item.type);
1835
1813
  results.push({ node_id: nodeId, ok: true, target: 'staged' });
1836
1814
  continue;
1837
1815
  }
@@ -1852,7 +1830,7 @@ function createMcpServer() {
1852
1830
  }
1853
1831
  db.upsertNode({
1854
1832
  id: existing.id,
1855
- type: existing.type,
1833
+ type: (item && item.type) ? String(item.type) : existing.type,
1856
1834
  name: existing.name,
1857
1835
  file_path: existing.file_path,
1858
1836
  signature: existing.signature,
@@ -1980,8 +1958,8 @@ function createMcpServer() {
1980
1958
  text: JSON.stringify({
1981
1959
  committed: false,
1982
1960
  message: otherSessionsPending > 0
1983
- ? `Nothing staged by this session. ${otherSessionsPending} entr(y/ies) from another session are pending but left untouched — call stage_change/edit_node first.`
1984
- : 'Nothing staged. Call stage_change first.'
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.'
1985
1963
  })
1986
1964
  }]
1987
1965
  };
@@ -2092,9 +2070,10 @@ function createMcpServer() {
2092
2070
  }
2093
2071
  // Local, gitignored activity log — never reaches the shared graph. entries/node_ids are
2094
2072
  // 1:1 in order (commitStagedChanges pushes both from the same loop), so index-matching
2095
- // recovers each edit's resolved node id. Entries with no code_before (stage_change, which
2096
- // takes a snapshot with nothing to diff against) contribute nothing here: there is no
2097
- // "before" to back up, so recording one would make revert restore a guess. Whole-file
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
2098
2077
  // edits (fileEdits — nothing traced into the graph) are folded in alongside them, so
2099
2078
  // every file edit_node touched shows up here, not just the ones that became graph nodes.
2100
2079
  //
@@ -2191,7 +2170,7 @@ function createMcpServer() {
2191
2170
  };
2192
2171
  }
2193
2172
  // ── Deprecated write handlers: NOT advertised in ListTools (superseded by
2194
- // stage_change/commit_changes), but retained so any direct/legacy call still works. ──
2173
+ // edit_node/commit_changes), but retained so any direct/legacy call still works. ──
2195
2174
  case 'add_node': {
2196
2175
  const devmindPath = resolveDevmindPath(args.devmind_path);
2197
2176
  const rawNodeId = requireStr(args, 'node_id', 'add_node');
@@ -2473,7 +2452,12 @@ function createMcpServer() {
2473
2452
  }
2474
2453
  case 'get_activity_log': {
2475
2454
  const devmindPath = resolveDevmindPath(args.devmind_path);
2476
- const result = (0, activity_1.queryActivityLog)(devmindPath, {
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, {
2477
2461
  developer: args.developer ? String(args.developer) : undefined,
2478
2462
  sessionId: args.session_id ? String(args.session_id) : undefined,
2479
2463
  sinceHours: args.since_hours !== undefined ? Number(args.since_hours) : undefined,