Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 87 additions & 12 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
// <codegraph_context> — the agent's grep/read reflex has nothing left to
// find. Every failure path is a silent no-op.
//
// The steered message keeps `role: 'user'` (so the model reads it as prompt
// context) but MUST NOT use `source.kind: 'user'` — that kind means "a human
// typed this" and the Chat UI prints it as a full user bubble. It carries
// `plugin:dsh-codegraph` instead, which the UI files as a collapsed
// injected-context row. See FRONTLOAD_SOURCE_KIND.
//
// Why a native plugin instead of a plain MCP server: codegraph's MCP server
// exposes ZERO tools while the workspace is unindexed (and tells the agent it
// should not index itself). This plugin always exposes the tools, including
Expand Down Expand Up @@ -62,8 +68,11 @@
// in the wild: intermittent "neither subprocess nor shell mounted" boot
// crashes). Lazy resolution also lets the tools start working if an executor
// mounts after this plugin. The `codegraph` executable must be on PATH (e.g.
// global `npm i -g @colbymchenry/codegraph` or the npm thin shim); a clearer
// error is thrown when it is missing.
// global `npm i -g @colbymchenry/codegraph` or the npm thin shim), or its
// full path given via the `codegraphPath` config option (cordis.patch.yml),
// with optional extra CLI arguments via `codegraphArgs` (an array of strings,
// prepended to every invocation); a clearer error is thrown when it is
// missing.

import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
Expand Down Expand Up @@ -91,10 +100,20 @@ export const inject = ['tools', 'systemPrompt']
// steers the result into the turn as <codegraph_context> — so the agent's
// grep/read reflex has nothing left to find. Every failure path is a
// silent no-op; it never breaks or blocks the user's prompt.
// - codegraphPath (default ''): full path to the `codegraph` executable. When
// set (e.g. in cordis.patch.yml), the tools invoke that executable directly
// (both the subprocess and shell execution paths). When empty, the
// executable is resolved from the system PATH as before.
// - codegraphArgs (default []): extra CLI arguments passed to the codegraph
// executable on every invocation, e.g. ['--no-color', '--threads', '4'].
// Only meaningful together with `codegraphPath` (with a PATH-resolved
// executable, pass such flags through the tool arguments instead).
export const Config = z.object({
guideSearch: z.boolean().default(true),
surface: z.union([z.const('core'), z.const('full')]).default('core'),
frontload: z.boolean().default(true)
frontload: z.boolean().default(true),
codegraphPath: z.string().default(''),
codegraphArgs: z.array(z.string()).default([])
})

// System-prompt guidance that makes the codegraph tools preferred for code
Expand Down Expand Up @@ -137,7 +156,7 @@ function shQuote(value) {
}

function executableHint() {
return '`codegraph` was not found on PATH. Install it once, e.g. `npm i -g @colbymchenry/codegraph` (or use the official install.sh / npm thin shim), then retry.'
return '`codegraph` was not found on PATH. Install it once, e.g. `npm i -g @colbymchenry/codegraph` (or use the official install.sh / npm thin shim), or set the `codegraphPath` option in cordis.patch.yml to the full path of the codegraph executable, then retry.'
}

// The core tool surface (see Config.surface): the index bootstrap/maintenance
Expand All @@ -157,6 +176,42 @@ const CORE_TOOLS = new Set([
// own output.
const FRONTLOAD_MARKER = '<codegraph_context'

// LOAD-BEARING: the durable `source.kind` of an injected message decides how
// the Chat UI files it, and `'user'` is the wrong answer.
//
// The client projects every durable `user/message` to a Chat node by
// `source.kind` (see @deepseek-ai/dsh-client-ui-chat):
// kind !== 'user' -> a "context" node: a COLLAPSED one-line row
// (icon + "injected context" + producer label), expanded
// only on demand, and folded into the turn's process
// disclosure. `source.form`/`source.summary` only shape
// how that row reads.
// kind === 'user' -> a real user/steering BUBBLE: the message body is
// printed in full in the transcript.
//
// So an injection that claims to be a user message renders as if the human had
// pasted the whole 12 KB explore dump — the exact complaint this fixes. Every
// DSH-native injection uses a non-'user' kind (`plugin:<name>`,
// `runtime-context`, `skill-catalog`, `agent-instructions`, `subagent-settled`,
// `tool-jobs`, ...), and so do the other plugins
// (`plugin:graph-memory`, `plugin:dsh-mcp-manager`). This plugin must too.
const FRONTLOAD_SOURCE_KIND = `plugin:${name}`

// The `notice` form gives the collapsed row a one-line account of what was
// injected, so the reader can tell an explore dump from any other injection
// without expanding it. The 120-character bound mirrors DSH's own
// CONTEXT_SUMMARY_MAX_CHARS, clamped here rather than imported so the plugin
// keeps working on host versions that predate that export.
const SUMMARY_MAX_CHARS = 120
function summarizeExplore(body) {
const found = /Found (\d+) symbols? across (\d+) files?/i.exec(body)
if (found) return `explore: ${found[1]} symbols in ${found[2]} files`
const firstLine = body.split('\n').map((line) => line.trim()).find((line) => line.length > 0)
const summary = firstLine ? firstLine.replace(/[*#>]/g, '').trim() : 'explore result'
if (!summary) return 'explore result'
return summary.length <= SUMMARY_MAX_CHARS ? summary : summary.slice(0, SUMMARY_MAX_CHARS - 1) + '…'
}

// Structural keywords (EN + zh) signalling a code-structure/flow question —
// the HIGH-confidence gate tier: fire explore without further verification.
const STRUCTURAL_KEYWORDS = [
Expand Down Expand Up @@ -201,7 +256,7 @@ function findIndexRoot(cwd) {

export function apply(ctx, config = {}) {
const tools = ctx.tools
const { guideSearch = true, surface = 'core', frontload = true } = config
const { guideSearch = true, surface = 'core', frontload = true, codegraphPath = '', codegraphArgs = [] } = config
const toolEnabled = (name) => surface === 'full' || CORE_TOOLS.has(name)

// Make the codegraph_* tools the preferred route for code search: instruct
Expand Down Expand Up @@ -242,14 +297,25 @@ export function apply(ctx, config = {}) {
}
if (sub !== undefined) {
if (resolvedExe.value === null) {
try {
resolvedExe.value = await sub.resolveExecutable('codegraph')
} catch {
throw new Error('dsh-codegraph: ' + executableHint())
if (codegraphPath) {
// Explicit full path from config (cordis.patch.yml) — use it as-is.
if (!existsSync(codegraphPath)) {
throw new Error(
`dsh-codegraph: configured codegraphPath "${codegraphPath}" does not exist. ` +
'Fix the path in cordis.patch.yml, or remove the option to resolve `codegraph` from PATH.'
)
}
resolvedExe.value = codegraphPath
} else {
try {
resolvedExe.value = await sub.resolveExecutable('codegraph')
} catch {
throw new Error('dsh-codegraph: ' + executableHint())
}
}
}
const proc = sub.spawn({
argv: [resolvedExe.value].concat(argv),
argv: [resolvedExe.value].concat(codegraphArgs).concat(argv),
cwd: cwd || '/',
stdio: {
stdin: 'ignore',
Expand All @@ -270,7 +336,8 @@ export function apply(ctx, config = {}) {
}
return text
}
const command = 'codegraph ' + argv.map(shQuote).join(' ')
const executable = codegraphPath || 'codegraph'
const command = executable + ' ' + [...codegraphArgs, ...argv].map(shQuote).join(' ')
const spec = shell.resolve({
command,
workdir: cwd || '/',
Expand Down Expand Up @@ -622,7 +689,15 @@ export function apply(ctx, config = {}) {
type: 'text',
text: `${FRONTLOAD_MARKER} note="Structural context from CodeGraph for this prompt — treat the returned source as already read; call codegraph_explore for more.">\n${body}\n</codegraph_context>`
}],
source: { kind: 'user' }
// NOT `{ kind: 'user' }`: a user-role injection must still carry a
// non-'user' source kind, or the Chat UI renders it as a human bubble
// instead of a collapsed injected-context row. See FRONTLOAD_SOURCE_KIND.
source: {
kind: FRONTLOAD_SOURCE_KIND,
plugin: name,
form: 'notice',
summary: summarizeExplore(body0)
}
}))
}

Expand Down