dsh-graft-plugin 0.1.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/src/command.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `/graft` — index this session's workspace, or report on it.
3
+ *
4
+ * WHY A COMMAND AND NOT JUST THE TOOLS
5
+ *
6
+ * Every graft tool needs a `graft/` index to exist, and until one does they all
7
+ * answer the same way: "No graft index for this workspace — run `graft build`
8
+ * in it once." That is a correct answer and a bad experience: it sends you to a
9
+ * terminal, in the right directory, to run a command whose name you have to
10
+ * remember, for a repo dsh already knows the path of.
11
+ *
12
+ * `/graft` closes that loop. It resolves the session's own workspace, builds
13
+ * there, and reports what it produced.
14
+ *
15
+ * WHAT `/graft` COSTS
16
+ *
17
+ * Nothing. The bare form runs the plain structural build — `$0`, offline, no
18
+ * API key — which is also what the auto-rebuild runs. `/graft deep` is the one
19
+ * form that calls an LLM, and it is never reached except by typing that word,
20
+ * which is the whole reason it is a separate subcommand rather than a flag on
21
+ * the default path.
22
+ *
23
+ * @module graft-status/command
24
+ */
25
+
26
+ import { join } from 'node:path'
27
+
28
+ import { runGraft } from './graft-tools.js'
29
+
30
+ /** How long a build may take before the command gives up, in ms. */
31
+ const BUILD_TIMEOUT_MS = 180_000
32
+
33
+ /** The workspace this invocation belongs to. */
34
+ export function workspaceOf(invocation, fallback) {
35
+ const cwd = invocation?.agent?.session?.header?.cwd
36
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : fallback
37
+ }
38
+
39
+ /**
40
+ * What the user asked for, from the text after the command name.
41
+ *
42
+ * Unknown words are an error rather than a silent build: someone typing
43
+ * `/graft rebiuld` meant something, and quietly doing the default would hide
44
+ * the typo behind a plausible-looking success.
45
+ */
46
+ export function parseArgs(rawInput) {
47
+ const word = String(rawInput ?? '').trim().toLowerCase()
48
+ if (word === '') return { mode: 'build' }
49
+ if (word === 'status' || word === 'check') return { mode: 'status' }
50
+ if (word === 'deep' || word === '--deep') return { mode: 'deep' }
51
+ return { mode: 'unknown', word }
52
+ }
53
+
54
+ /**
55
+ * The stats for a root, however they can be had.
56
+ *
57
+ * A plain `graft build` writes the graph but NOT `graft/.cache/stats.json` —
58
+ * that file is written by graft's `sync-run`, which builds and then patches it.
59
+ * So straight after an initialise there are no stats to read, and reporting
60
+ * "no stats written" for a build that just succeeded reads as a failure. The
61
+ * wiring graph is the same numbers one layer down; this is the precedence the
62
+ * status chip already uses, kept identical so the two never disagree.
63
+ */
64
+ async function statsFor(root, deps) {
65
+ const stats = await deps.readJson(join(root, 'graft', '.cache', 'stats.json'))
66
+ if (stats !== undefined && typeof stats.nodeCount === 'number') return stats
67
+ const wiring = await deps.readJson(join(root, 'graft', '.graph', 'wiring.json'))
68
+ const counted = deps.countWiring?.(wiring)
69
+ if (counted === undefined) return undefined
70
+ // The graph keeps its summary under `meta`, not at the top level — the same
71
+ // place its own nodeCount/edgeCount live.
72
+ return { ...counted, languages: wiring?.meta?.languages ?? wiring?.languages }
73
+ }
74
+
75
+ /** A one-line summary of a built graph. */
76
+ function describe(stats) {
77
+ if (stats === undefined) return 'no graph written'
78
+ const nodes = typeof stats.nodeCount === 'number' ? stats.nodeCount.toLocaleString('en-US') : '?'
79
+ const edges = typeof stats.edgeCount === 'number' ? stats.edgeCount.toLocaleString('en-US') : '?'
80
+ const languages = Array.isArray(stats.languages) && stats.languages.length > 0
81
+ ? ` · ${stats.languages.join(', ')}`
82
+ : ''
83
+ return `${nodes} nodes / ${edges} edges${languages}`
84
+ }
85
+
86
+ export function createGraftCommand(deps) {
87
+ return {
88
+ name: 'graft',
89
+ description: 'Index this workspace for graft, or report on its graph',
90
+ input: { hint: '[status|deep]' },
91
+ handler: async (invocation) => {
92
+ const cwd = workspaceOf(invocation, deps.fallbackCwd)
93
+ const args = parseArgs(invocation?.rawInput)
94
+
95
+ if (args.mode === 'unknown') {
96
+ return {
97
+ kind: 'error',
98
+ text: `Unknown option "${args.word}". Use /graft, /graft status, or /graft deep.`,
99
+ }
100
+ }
101
+
102
+ const existing = await deps.findRoot(cwd)
103
+
104
+ if (args.mode === 'status') {
105
+ if (existing === undefined) {
106
+ return { kind: 'success', text: `No graft index for ${cwd}. Run /graft to build one.` }
107
+ }
108
+ const stats = await statsFor(existing, deps)
109
+ const drift = stats?.dirty === true ? ' · stale, run /graft' : ' · in sync'
110
+ return { kind: 'success', text: `graft: ${existing}\n${describe(stats)}${drift}` }
111
+ }
112
+
113
+ // An index found ABOVE the workspace is rebuilt where it lives; without
114
+ // one, the workspace itself becomes the new root. That is what makes the
115
+ // bare command mean "initialize here" in a fresh repo and "refresh" in a
116
+ // repo already indexed, with no flag to choose between them.
117
+ const target = existing ?? cwd
118
+ const initialising = existing === undefined
119
+
120
+ const result = await runGraft(
121
+ args.mode === 'deep' ? ['build', '--deep', target] : ['build', target],
122
+ target,
123
+ { ...deps, timeoutMs: BUILD_TIMEOUT_MS, signal: invocation?.signal },
124
+ )
125
+
126
+ const stats = await statsFor(target, deps)
127
+ const built = await deps.findRoot(target)
128
+ if (built === undefined) {
129
+ // The build produced no index. graft's own message is the useful part —
130
+ // an unsupported language, an empty tree, a missing key on --deep.
131
+ return {
132
+ kind: 'error',
133
+ text: `graft build did not produce an index in ${target}.\n\n${result.text.slice(0, 1200)}`,
134
+ }
135
+ }
136
+
137
+ const headline = initialising ? `graft initialised in ${target}` : `graft rebuilt for ${target}`
138
+ const deepNote = args.mode === 'deep'
139
+ ? '\nDeep pass done — the meaning layer and manifest.json now exist.'
140
+ : ''
141
+ return {
142
+ kind: 'success',
143
+ text: `${headline}\n${describe(stats)}${deepNote}\n\nThe graft tools now work in this session, and the graph rebuilds itself at the end of any turn that changes files.`,
144
+ }
145
+ },
146
+ }
147
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Session-aware graft tools.
3
+ *
4
+ * WHY THESE EXIST INSTEAD OF THE MCP SERVER
5
+ *
6
+ * `graft mcp` is a stdio child spawned once per dsh boot, and it resolves the
7
+ * repo it serves — "nearest ancestor with a graft/ index" — from ITS working
8
+ * directory at startup. Nothing can re-point it afterwards: its tools take no
9
+ * directory argument, and the `in` parameter scopes to a sub-project WITHIN the
10
+ * root it already chose.
11
+ *
12
+ * That is incompatible with how this dsh is used: launched once from wherever,
13
+ * with the working directory chosen per session inside the UI. Measured on one
14
+ * repo, changing only the launch directory:
15
+ *
16
+ * launched in the workspace graft_repo_map ok, find_code ok, find_all ok
17
+ * launched from a home dir "no graph found — run `graft build` first"
18
+ *
19
+ * A tool registered here has what the MCP server cannot get: the call's own
20
+ * agent, and therefore `agent.session.header.cwd` — the workspace that session
21
+ * actually chose. So the repo is resolved per call, and one dsh can serve
22
+ * several workspaces at once.
23
+ *
24
+ * WHAT THEY ARE
25
+ *
26
+ * Thin wrappers over the `graft` CLI, which is the same graph the MCP server
27
+ * reads and needs no LLM key. Each maps to one subcommand and passes the
28
+ * resolved root as its `[dir]` argument.
29
+ *
30
+ * NO BOOLEAN PARAMETERS, DELIBERATELY
31
+ *
32
+ * On this deployment a tool call that SETS a boolean is emitted by the server
33
+ * as literal `<tool_call>` text instead of a structured call, and never runs
34
+ * (see the bool-param-compat plugin). These take string enums instead, which
35
+ * read better to a model anyway — `detail: "full"` says more than `full: true`
36
+ * — and cannot trip that bug even if that plugin is removed.
37
+ *
38
+ * @module graft-status/graft-tools
39
+ */
40
+
41
+ import { defineTool } from '@deepseek-ai/dsh-tools'
42
+ import { spawn } from 'node:child_process'
43
+ import { existsSync } from 'node:fs'
44
+ import { delimiter, join } from 'node:path'
45
+
46
+ /**
47
+ * Ceiling for one graft call.
48
+ *
49
+ * Every subcommand used here is the `$0, no LLM` kind — it reads a prebuilt
50
+ * graph — so a slow one means something is wrong rather than something is
51
+ * thinking. Declared on the tool so dsh's own timeout policy enforces it;
52
+ * without a declared `timeoutMs` that policy takes an early return and the
53
+ * call is unbounded.
54
+ */
55
+ const TIMEOUT_MS = 60_000
56
+
57
+ /** Cap on returned text, so one broad query cannot swamp the context. */
58
+ const MAX_CHARS = 60_000
59
+
60
+ /**
61
+ * How to invoke graft without a shell.
62
+ *
63
+ * `graft` on Windows is a `.cmd` shim, so a naive spawn cannot exec it and the
64
+ * obvious fix — `shell: true` — is a trap: Node joins argv into one command
65
+ * line WITHOUT quoting, so the first argument containing a space is silently
66
+ * split. That broke every call here at once, because both the queries
67
+ * (`"punch animation"`) and the repo path (`B:\...\Street Yeet`) contain
68
+ * spaces.
69
+ *
70
+ * The shim's own body is `node <dir>/node_modules/@nanonets/graft/dist/cli.js`,
71
+ * so finding that file on PATH and running it with the current Node needs no
72
+ * shell at all, and argv is passed through exactly as written.
73
+ *
74
+ * The bare-name fallback keeps a differently-installed graft working; it is
75
+ * only reached when the entry point cannot be found.
76
+ */
77
+ export function resolveGraftCommand(env = process.env, exists = existsSync, execPath = process.execPath) {
78
+ const path = String(env.PATH ?? env.Path ?? '')
79
+ for (const dir of path.split(delimiter)) {
80
+ if (dir === '') continue
81
+ const cli = join(dir, 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
82
+ if (exists(cli)) return { command: execPath, prefix: [cli], shell: false }
83
+ }
84
+ return { command: 'graft', prefix: [], shell: process.platform === 'win32' }
85
+ }
86
+
87
+ let cachedCommand
88
+
89
+ /**
90
+ * Run one graft subcommand and return its stdout.
91
+ *
92
+ * stderr is folded into the result rather than dropped: graft reports "no
93
+ * graph", drift warnings and bad-argument errors there, and a tool that
94
+ * swallowed them would answer "(no output)" to a question that has a specific,
95
+ * actionable reason behind it.
96
+ */
97
+ export function runGraft(args, cwd, deps = {}) {
98
+ const spawnFn = deps.spawn ?? spawn
99
+ const how = deps.command ?? (cachedCommand ??= resolveGraftCommand())
100
+ return new Promise((resolve) => {
101
+ let child
102
+ try {
103
+ child = spawnFn(how.command, [...how.prefix, ...args], {
104
+ cwd,
105
+ shell: how.shell,
106
+ windowsHide: true,
107
+ stdio: ['ignore', 'pipe', 'pipe'],
108
+ })
109
+ } catch (error) {
110
+ resolve({ text: `graft could not be run: ${String(error?.message ?? error)}` })
111
+ return
112
+ }
113
+
114
+ let out = ''
115
+ let err = ''
116
+ let settled = false
117
+ let timer
118
+ let abort
119
+ const finish = (text) => {
120
+ if (settled) return
121
+ settled = true
122
+ clearTimeout(timer)
123
+ if (abort !== undefined) deps.signal?.removeEventListener?.('abort', abort)
124
+ resolve({ text: text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n\n[truncated]` : text })
125
+ }
126
+
127
+ // A build is the one call here that can genuinely run long, so both an
128
+ // upper bound and the caller's own cancellation have to reach the child —
129
+ // otherwise a wedged graft holds a slash command open with no way out.
130
+ const limit = Number.isInteger(deps.timeoutMs) && deps.timeoutMs > 0 ? deps.timeoutMs : TIMEOUT_MS
131
+ timer = setTimeout(() => {
132
+ child.kill()
133
+ finish(`graft timed out after ${Math.round(limit / 1000)}s.\n${out}${err}`)
134
+ }, limit)
135
+ if (deps.signal !== undefined) {
136
+ abort = () => {
137
+ child.kill()
138
+ finish('Cancelled.')
139
+ }
140
+ if (deps.signal.aborted) abort()
141
+ else deps.signal.addEventListener?.('abort', abort, { once: true })
142
+ }
143
+
144
+ child.stdout?.on('data', (chunk) => { out += chunk.toString() })
145
+ child.stderr?.on('data', (chunk) => { err += chunk.toString() })
146
+ child.on('error', (error) => finish(`graft could not be run: ${String(error?.message ?? error)}`))
147
+ child.on('close', () => {
148
+ const combined = [out.trim(), err.trim()].filter(Boolean).join('\n')
149
+ finish(combined === '' ? '(graft produced no output)' : combined)
150
+ })
151
+ })
152
+ }
153
+
154
+ /**
155
+ * The repo root for the call's own session.
156
+ *
157
+ * `exec.agent.session.header.cwd` is the workspace chosen inside dsh for THIS
158
+ * session — the value its entry in the session list shows — which is the whole
159
+ * reason these tools exist. The process cwd is only the fallback, for a call
160
+ * with no agent (a direct dispatch) or a session created without one.
161
+ */
162
+ export function repoFor(exec, fallback) {
163
+ const cwd = exec?.agent?.session?.header?.cwd
164
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : fallback
165
+ }
166
+
167
+ /** `graft <sub> … <root>`, with the shared plumbing applied. */
168
+ function graftTool(spec, deps) {
169
+ return defineTool({
170
+ name: spec.name,
171
+ description: spec.description,
172
+ parameters: spec.parameters,
173
+ timeoutMs: TIMEOUT_MS,
174
+ output: {
175
+ schema: {
176
+ type: 'object',
177
+ properties: { text: { type: 'string' } },
178
+ additionalProperties: false,
179
+ },
180
+ render: (_args, value) => [{ type: 'text', text: value.text }],
181
+ },
182
+ execute: async (args, exec) => {
183
+ const root = repoFor(exec, deps.fallbackCwd)
184
+ const found = await deps.findRoot(root)
185
+ if (found === undefined) {
186
+ return {
187
+ text:
188
+ `No graft index for this workspace (${root}).\n` +
189
+ 'Run `graft build` in it once — it needs no API key — and this tool will work from then on.',
190
+ }
191
+ }
192
+ const result = await runGraft([...spec.argv(args), found], found, deps)
193
+ const saved = savedTokensIn(result.text)
194
+ if (saved > 0) deps.recordSavings?.(found, saved)
195
+ return result
196
+ },
197
+ })
198
+ }
199
+
200
+ /** Only pass a flag when the model actually chose the non-default. */
201
+ const when = (condition, ...flag) => (condition ? flag : [])
202
+
203
+ /**
204
+ * The token count graft claims it saved on one call.
205
+ *
206
+ * Every retrieval command opens its output with a line like
207
+ * `[graft] tokens saved ≈ 26,730 (99%) — this output ≈ 308 tok vs …`, which is
208
+ * graft's own estimate and the same number its Claude Code statusline totals.
209
+ * Reading it back is what lets the dsh chip show a running total too: graft
210
+ * keeps that tally in per-session files written by ITS hooks, and dsh has none,
211
+ * so without parsing it here the figure would sit at zero forever no matter how
212
+ * much the tools were used.
213
+ *
214
+ * Digit grouping is stripped rather than parsed by locale — graft renders these
215
+ * with the host's separators (`1,50,427` on this machine's Indian grouping), so
216
+ * anything that is not a digit between the number's ends is punctuation.
217
+ */
218
+ export function savedTokensIn(text) {
219
+ const match = /tokens saved\s*≈\s*([\d.,   ]+?)\s*\(/.exec(String(text ?? ''))
220
+ if (match === null) return 0
221
+ const digits = match[1].replace(/\D/g, '')
222
+ if (digits === '') return 0
223
+ const value = Number.parseInt(digits, 10)
224
+ return Number.isSafeInteger(value) && value > 0 ? value : 0
225
+ }
226
+
227
+ export function graftTools(deps) {
228
+ return [
229
+ graftTool(
230
+ {
231
+ name: 'graft_ask',
232
+ description:
233
+ 'Search this repo\'s prebuilt code graph in plain words and get ranked results with the ' +
234
+ 'code inlined at exact file:line. The first thing to reach for on "how does X work" or ' +
235
+ '"where is Y" — one call usually replaces several file reads. Costs no API key.',
236
+ parameters: {
237
+ query: { type: 'string', required: true, description: 'What you are looking for, in plain words.' },
238
+ detail: {
239
+ type: 'string',
240
+ enum: ['crux', 'full'],
241
+ description: 'How much of each hit to inline: "crux" (default, <=8 lines) or "full" (the whole span).',
242
+ },
243
+ in: { type: 'string', description: 'Limit to one sub-project path, for a monorepo (e.g. "packages/core/").' },
244
+ },
245
+ argv: (args) => [
246
+ 'ask', String(args.query), '--source',
247
+ ...when(args.detail === 'full', '--full'),
248
+ ...when(typeof args.in === 'string' && args.in !== '', '--in', String(args.in)),
249
+ ],
250
+ },
251
+ deps,
252
+ ),
253
+
254
+ graftTool(
255
+ {
256
+ name: 'graft_grep',
257
+ description:
258
+ 'Exhaustive search over the indexed files, with hits grouped by their enclosing symbol ' +
259
+ 'and ranked by how much depends on them. Use when you need EVERY occurrence — graft_ask ' +
260
+ 'is ranked top-N and will miss some.',
261
+ parameters: {
262
+ pattern: { type: 'string', required: true, description: 'Regular expression, or a literal when match is "literal".' },
263
+ match: {
264
+ type: 'string',
265
+ enum: ['regex', 'literal'],
266
+ description: 'Treat pattern as a regex (default) or as literal text.',
267
+ },
268
+ case: {
269
+ type: 'string',
270
+ enum: ['sensitive', 'insensitive'],
271
+ description: 'Case sensitivity; defaults to sensitive.',
272
+ },
273
+ in: { type: 'string', description: 'Limit to one sub-project path, for a monorepo.' },
274
+ },
275
+ argv: (args) => [
276
+ 'grep', String(args.pattern),
277
+ ...when(args.match === 'literal', '--fixed'),
278
+ ...when(args.case === 'insensitive', '--ignore-case'),
279
+ ...when(typeof args.in === 'string' && args.in !== '', '--in', String(args.in)),
280
+ ],
281
+ },
282
+ deps,
283
+ ),
284
+
285
+ graftTool(
286
+ {
287
+ name: 'graft_callers',
288
+ description:
289
+ 'The exact call/import/extends edges for one symbol: who calls it (default), what it ' +
290
+ 'calls, or the full blast radius. Run this BEFORE renaming or changing a symbol — ' +
291
+ 'editing the obvious file and missing its callers is the classic mistake.',
292
+ parameters: {
293
+ symbol: { type: 'string', required: true, description: 'The symbol name to trace.' },
294
+ direction: {
295
+ type: 'string',
296
+ enum: ['in', 'out'],
297
+ description: '"in" (default) = who calls it; "out" = what it calls.',
298
+ },
299
+ depth: {
300
+ type: 'string',
301
+ description: 'How far to walk: a number ("2"), or "all" for every connected source. Default 1.',
302
+ },
303
+ in: { type: 'string', description: 'Limit to one sub-project path, for a monorepo.' },
304
+ },
305
+ argv: (args) => [
306
+ 'callers', String(args.symbol),
307
+ ...when(args.direction === 'out', '--direction', 'out'),
308
+ ...when(typeof args.depth === 'string' && args.depth !== '', '--depth', String(args.depth)),
309
+ ...when(typeof args.in === 'string' && args.in !== '', '--in', String(args.in)),
310
+ ],
311
+ },
312
+ deps,
313
+ ),
314
+
315
+ graftTool(
316
+ {
317
+ name: 'graft_skeleton',
318
+ description:
319
+ 'One file\'s whole API — every signature with its line span, no bodies. About a tenth ' +
320
+ 'the tokens of reading the file, and usually enough to decide what to open.',
321
+ parameters: {
322
+ file: { type: 'string', required: true, description: 'Repo-relative path to the file.' },
323
+ },
324
+ argv: (args) => ['skeleton', String(args.file)],
325
+ },
326
+ deps,
327
+ ),
328
+
329
+ graftTool(
330
+ {
331
+ name: 'graft_map',
332
+ description:
333
+ 'Orientation in an unfamiliar repo: directory clusters, per-directory hubs, and the ' +
334
+ 'global hotspots. One call is the answer — do not follow it by reading every file it names.',
335
+ parameters: {
336
+ max_dirs: { type: 'number', description: 'Cap on the directories listed.' },
337
+ },
338
+ argv: (args) => [
339
+ 'map',
340
+ ...when(Number.isFinite(args.max_dirs) && args.max_dirs > 0, '--max-dirs', String(args.max_dirs)),
341
+ ],
342
+ },
343
+ deps,
344
+ ),
345
+ ]
346
+ }