dsh-plugin-prompt-tool 0.3.1 → 0.4.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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * anchored-context-gate — reusable unified injection control for ANY preset.
3
+ *
4
+ * Mount this one plugin to keep a session's first model request free of
5
+ * auto-injected context, whatever its source, and to have every injection
6
+ * return on the second round. It intercepts the harness's two unified
7
+ * injection paths — not a per-source denylist — so it covers sources that do
8
+ * not exist yet:
9
+ *
10
+ * a. RUNTIME CONTEXT (system-prompt/assemble): while the session is
11
+ * unpromoted, the assembly's `contexts` are blanked. That covers the
12
+ * WHOLE `SystemPrompt.context()` family — the sandbox and approval
13
+ * policy snapshots and any third-party context provider — without
14
+ * enumerating them. The loop's own snapshot projection then emits no
15
+ * message during the gate (no snapshot ever existed), and at the first
16
+ * promoted request it emits exactly ONE fresh snapshot: "minimal first
17
+ * round, inject on the second round" falls out of the projection's
18
+ * diffing, with no reinjection logic here.
19
+ *
20
+ * b. STEP MESSAGES (agent/pre-step): the waterfall payload carries the
21
+ * CLAIMED message batch (the inbox messages this step owns). While
22
+ * unpromoted, the gate keeps exactly the claimed messages plus a small
23
+ * kind allowlist, and strips everything any listener appended — skill
24
+ * catalog, AGENTS.md digest, time/tmux context, hooks, unknown
25
+ * third-party plugins — by DEFAULT, regardless of source identity. The
26
+ * default allowlist is `['skill-invocation']`: a user-initiated skill
27
+ * gesture is not an automatic injection, and stripping it would lose the
28
+ * skill content once the gesture scrolls out of the per-step claim.
29
+ * Durable history (compaction summaries included) never passes through
30
+ * this gate: it enters the request via the session surface, not the
31
+ * pre-step waterfall.
32
+ *
33
+ * The phase is the same epoch-aware promotion machine the anchored presets
34
+ * use (see compaction-epoch.mjs): a durable `tool/call` and/or
35
+ * `assistant/message` (per `promoteOn`, default `either`) promotes, and a
36
+ * `compaction/end` boundary demotes again — the first post-compaction request
37
+ * is a "second first request" and is gated the same way. Derived from durable
38
+ * events, so resume and reload preserve it.
39
+ *
40
+ * SUBAGENTS: by default subagents (delegationDepth > 0) skip the gate (their
41
+ * first request already sees full context). `includeSubagents: true` gates
42
+ * them too — their first request is clean and their own first reply or tool
43
+ * call opens the gate — so a delegation cannot reintroduce an uncontrolled
44
+ * first request. Keep this flag in sync with any companion phase plugin
45
+ * (e.g. the tool-bootstrap row).
46
+ *
47
+ * CONFIG:
48
+ * - `promoteOn`: 'either' (default) | 'tool-call' | 'assistant-message'.
49
+ * - `includeSubagents`: boolean, default false.
50
+ * - `enabled`: boolean, default true. `false` disables both interception
51
+ * paths (A/B testing without touching the row set).
52
+ * - `allowKinds`: message `source.kind` names allowed beyond the claimed
53
+ * batch, default ['skill-invocation']. An explicitly empty array keeps
54
+ * ONLY the claimed batch.
55
+ *
56
+ * ROW ORDER: mount this row FIRST in the composition. Waterfall after-next
57
+ * transforms apply in reverse registration order, so registering first (plus
58
+ * the pre-step listener's `prepend: true`) makes the gate the outermost
59
+ * transform — nothing registered later re-injects past it.
60
+ *
61
+ * Robustness: both filters degrade to "keep everything" on their own
62
+ * failures — a gate bug must never eat the user's context — and invalid
63
+ * config fails at apply time, i.e. at preset mount, where it is visible.
64
+ */
65
+
66
+ import { createEpochPromotion } from './compaction-epoch.mjs'
67
+ import { booleanOption, createWarnOnce, parsePromoteOn, validateConfig } from './shared.mjs'
68
+
69
+ /** Cordis plugin name used by loader diagnostics. */
70
+ export const name = 'anchored-context-gate'
71
+
72
+ /**
73
+ * Deliberately NO inject list: the listeners only touch services at event
74
+ * time, and applying without an inject lets this row register before the
75
+ * context-injecting plugins (dsh-agent-instructions, dsh-tool-skill, host
76
+ * plane policy projections) when it sits first in the composition.
77
+ */
78
+ export const inject = []
79
+
80
+ /** Every config key this plugin accepts — anything else is a typo. */
81
+ const ALLOWED_KEYS = new Set(['promoteOn', 'includeSubagents', 'enabled', 'allowKinds'])
82
+
83
+ /**
84
+ * Message kinds allowed through the pre-step gate beyond the claimed batch.
85
+ * A user-initiated skill gesture is the only default entry: it is not an
86
+ * automatic injection (see the header note).
87
+ */
88
+ const DEFAULT_ALLOW_KINDS = ['skill-invocation']
89
+
90
+
91
+ /**
92
+ * Validate the kind allowlist. An explicitly empty array is meaningful: keep
93
+ * ONLY the claimed batch, stripping even user skill gestures.
94
+ */
95
+ function allowKindList(value, field) {
96
+ if (value === undefined) return new Set(DEFAULT_ALLOW_KINDS)
97
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
98
+ throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
99
+ }
100
+ return new Set(value)
101
+ }
102
+
103
+
104
+ /** Register the unified context gate. */
105
+ export function apply(ctx, config) {
106
+ const source = validateConfig(name, config, ALLOWED_KEYS)
107
+ const promoteEvents = parsePromoteOn(name, source.promoteOn)
108
+ const includeSubagents = booleanOption(name, source.includeSubagents, 'includeSubagents', false)
109
+ const enabled = booleanOption(name, source.enabled, 'enabled', true)
110
+ const allowKinds = allowKindList(source.allowKinds, 'allowKinds')
111
+
112
+ const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
113
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
114
+
115
+ const warnOnce = createWarnOnce(ctx, name)
116
+
117
+ // Path (a): blank the dynamic runtime-context contributions while the
118
+ // session is unpromoted. Covers the whole SystemPrompt.context() family
119
+ // without enumerating it; the loop's snapshot projection then stays silent
120
+ // and diffs exactly ONE fresh snapshot in at the first promoted request.
121
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
122
+ // Downstream errors propagate untouched; only this filter's own logic is guarded.
123
+ const assembled = await next()
124
+ if (enabled === false) return assembled
125
+ try {
126
+ if (promotion.status(context.agent).promoted) return assembled
127
+ if (!Array.isArray(assembled.contexts) || assembled.contexts.length === 0) return assembled
128
+ return { ...assembled, contexts: [] }
129
+ } catch (error) {
130
+ // A gate bug must never break assembly: degrade to the assembled value.
131
+ warnOnce(`${name}: runtime-context suppression failed, keeping contexts: ${String((error && error.message) || error)}`)
132
+ return assembled
133
+ }
134
+ })
135
+
136
+ // Path (b): claimed-baseline deny on the pre-step waterfall. The payload's
137
+ // `messages` is the batch this step CLAIMED from the inbox — the baseline
138
+ // every injection appends to. Keep that baseline plus the kind allowlist,
139
+ // strip every appended message regardless of its source identity.
140
+ ctx.on('agent/pre-step', async ({ agent, messages: claimed }, next) => {
141
+ // Downstream errors propagate untouched; only this filter's own logic is guarded.
142
+ const decision = await next()
143
+ if (decision.kind === 'reject') return decision
144
+ if (enabled === false) return decision
145
+ try {
146
+ if (promotion.status(agent).promoted) return decision
147
+ if (!Array.isArray(decision.messages)) return decision
148
+ if (!Array.isArray(claimed)) return decision
149
+ const baseline = new Set(claimed)
150
+ const baselineIds = new Set(claimed
151
+ .map((message) => message?.id)
152
+ .filter((id) => id !== undefined && id !== null))
153
+ const kept = decision.messages.filter((message) =>
154
+ baseline.has(message)
155
+ || (message?.id !== undefined && message?.id !== null && baselineIds.has(message.id))
156
+ || allowKinds.has(message?.source?.kind),
157
+ )
158
+ return kept.length === decision.messages.length ? decision : { ...decision, messages: kept }
159
+ } catch (error) {
160
+ // A gate bug must never eat context: degrade to keeping every message.
161
+ warnOnce(`${name}: pre-step gate failed, keeping injected context: ${String((error && error.message) || error)}`)
162
+ return decision
163
+ }
164
+ }, { prepend: true })
165
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * custom-bash — a Windows-capable `bash` tool that registers under the SAME
3
+ * name (`bash`) as the official persistent bash, with a Minimal-compatible
4
+ * description, but executes through `ctx.subprocess.spawn` instead of a PTY.
5
+ *
6
+ * WHY: DeepSeek's first-request trajectory anchor keys on the tool SCHEMA
7
+ * matching the RL training distribution (issue #11: persistent
8
+ * bash + str_replace_editor anchored 5/5 at maxTokens=256000, pwsh/read
9
+ * 8/8 standard-like). The official persistent bash uses a PTY, and DSH's PTY
10
+ * backend is linux/darwin-only — `subprocess-local` throws "terminal
11
+ * inspection is unsupported on platform win32". A custom tool that presents
12
+ * the same name and a Minimal-like description but spawns Git Bash through
13
+ * the ordinary (cross-platform) subprocess seam keeps the schema anchor
14
+ * without the PTY dependency.
15
+ *
16
+ * Executable resolution (config `bashPath`, issue #24 — no hardcoded install
17
+ * path): an explicit non-empty `bashPath` wins unconditionally. Unset, the
18
+ * Git Bash executable is INFERRED, in probe order:
19
+ * 1. the `git` executable on PATH — its install root carries `bin\bash.exe`
20
+ * one level up from `cmd\`, beside `bin\`, or two levels up from
21
+ * `mingw64\bin\` (the standard installer, choco, and winget all resolve
22
+ * here; a scoop SHIM does not — its directory is the shims root, not the
23
+ * app — which is what step 2 covers);
24
+ * 2. the well-known Git-for-Windows roots derived from environment variables
25
+ * (`ProgramFiles`, `ProgramFiles(x86)`, per-user `LOCALAPPDATA\Programs
26
+ * \Git`, scoop's `~\scoop\apps\git\current` junction);
27
+ * 3. plain `bash` through `ctx.subprocess.resolveExecutable` (PATH lookup —
28
+ * last resort, since on Windows that may pick the WSL shim; WSL bash is
29
+ * still true bash, only the filesystem paths shift to /mnt/…).
30
+ *
31
+ * If NOTHING resolves, the tool fails with an actionable error naming the
32
+ * remedies — it does NOT silently execute under a different shell: the
33
+ * schema above promises `bash -c` semantics, and pwsh/cmd are different
34
+ * command languages. PowerShell stays available as its OWN tool (`pwsh`,
35
+ * present in the promoted catalog on Windows).
36
+ *
37
+ * Semantics mirror the official bash tool: `bash -c <command>` in a fresh
38
+ * process, bounded output, non-zero exit reported not thrown. No sandbox
39
+ * confinement on Windows (the sandbox backend is linux-only); the tool
40
+ * description says so. The bootstrap catalog pairs this with
41
+ * `str_replace_editor` (Minimal's two tools).
42
+ */
43
+
44
+ import { access } from 'node:fs/promises'
45
+ import { dirname, join } from 'node:path'
46
+
47
+ /** Cordis plugin name used by loader diagnostics. */
48
+ export const name = 'custom-bash'
49
+
50
+ /** The subprocess and tools services must exist before this tool can register. */
51
+ export const inject = ['subprocess', 'tools']
52
+
53
+ const DEFAULT_TIMEOUT_MS = 120000
54
+ const DEFAULT_MAX_OUTPUT_BYTES = 64000
55
+
56
+ /**
57
+ * Git Bash candidate paths, in probe order (see the header): the `git`
58
+ * executable's install root first, then the well-known env-derived roots.
59
+ * Exported for tests; pure — existence probing happens at the call site.
60
+ */
61
+ export function bashCandidates(env, gitExe) {
62
+ const candidates = []
63
+ // git at <root>\cmd\git.exe (installer/scoop) or <root>\bin\git.exe →
64
+ // <root>\bin\bash.exe; <root>\mingw64\bin\git.exe (portable) → two up.
65
+ // A bare relative name means `git` did not actually resolve to a path.
66
+ if (typeof gitExe === 'string' && /[/\\]/.test(gitExe)) {
67
+ const dir = dirname(gitExe)
68
+ const root = dirname(dir)
69
+ candidates.push(
70
+ join(root, 'bin', 'bash.exe'),
71
+ join(dir, 'bash.exe'),
72
+ join(dirname(root), 'bin', 'bash.exe'),
73
+ )
74
+ }
75
+ if (env.ProgramFiles) candidates.push(join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'))
76
+ if (env['ProgramFiles(x86)']) candidates.push(join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'))
77
+ if (env.LOCALAPPDATA) candidates.push(join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'))
78
+ if (env.USERPROFILE) candidates.push(join(env.USERPROFILE, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'))
79
+ // Layouts overlap (a `bin` git.exe derives the same bash twice) — probe
80
+ // order survives the dedupe, insertion order is preserved.
81
+ return [...new Set(candidates)]
82
+ }
83
+
84
+ /** Tool parameter schema for the model-facing command. */
85
+ const commandSchema = {
86
+ type: 'object',
87
+ properties: {
88
+ command: {
89
+ type: 'string',
90
+ description: 'The bash command to execute (`bash -c` string domain).',
91
+ },
92
+ workdir: {
93
+ type: 'string',
94
+ description: 'Optional working directory; defaults to the session cwd.',
95
+ },
96
+ },
97
+ required: ['command'],
98
+ additionalProperties: false,
99
+ }
100
+
101
+ /** Register the model-facing `bash` tool. */
102
+ export function apply(ctx, config) {
103
+ const explicitBashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : undefined
104
+ const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
105
+ const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
106
+
107
+ // The inferred executable is memoized per plugin instance: candidate probing
108
+ // walks the filesystem, and the answer cannot change within a mount. A
109
+ // failed inference is NOT memoized — the plain `bash` fallback resolves
110
+ // fresh on every execute until some probe succeeds.
111
+ let inferredShell
112
+ const exists = (path) => access(path).then(() => true, () => false)
113
+ const resolveShell = async (signal) => {
114
+ if (explicitBashPath !== undefined) {
115
+ // A misconfigured explicit path must fail as itself, not as a
116
+ // discovery miss — the raw resolution error says which path failed.
117
+ return ctx.subprocess.resolveExecutable(explicitBashPath, undefined, signal)
118
+ }
119
+ if (inferredShell !== undefined) {
120
+ return ctx.subprocess.resolveExecutable(inferredShell, undefined, signal)
121
+ }
122
+ let gitExe
123
+ try {
124
+ gitExe = await ctx.subprocess.resolveExecutable('git', undefined, signal)
125
+ } catch {
126
+ // git unresolvable → the env-derived candidates below still apply
127
+ }
128
+ for (const candidate of bashCandidates(process.env, gitExe)) {
129
+ if (!(await exists(candidate))) continue
130
+ try {
131
+ inferredShell = await ctx.subprocess.resolveExecutable(candidate, undefined, signal)
132
+ return inferredShell
133
+ } catch {
134
+ // Exists but unresolvable (EPERM, a broken scoop junction): keep
135
+ // probing — one bad root must not block the rest of the chain, and
136
+ // nothing is memoized so later executes can still find a good one.
137
+ continue
138
+ }
139
+ }
140
+ try {
141
+ return await ctx.subprocess.resolveExecutable('bash', undefined, signal)
142
+ } catch (error) {
143
+ // Total discovery failure (no Git Bash root, no env root, no bash on
144
+ // PATH): name the remedies instead of leaking a raw ENOENT. Never
145
+ // fall back to pwsh/cmd here — the schema promises `bash -c`
146
+ // semantics; a different shell would silently break every command.
147
+ throw new Error(`bash executable not found — install Git for Windows, expose a bash on PATH, or set the custom-bash \`bashPath\` config (${String((error && error.message) || error)})`)
148
+ }
149
+ }
150
+
151
+ ctx.tools.register({
152
+ name: 'bash',
153
+ description: [
154
+ 'Run commands in a bash shell (Git Bash on Windows)',
155
+ '* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
156
+ "* You don't have access to the internet via this tool.",
157
+ '* You do have access to a mirror of common linux and python packages via apt and pip.',
158
+ '* State does NOT persist across command calls: each call runs in a fresh shell.',
159
+ "* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
160
+ '* Please avoid commands that may produce a very large amount of output.',
161
+ '* NOTE: runs without OS sandbox confinement on Windows (no landlock); treat output as untrusted.',
162
+ ].join('\n'),
163
+ parameters: commandSchema,
164
+ output: {
165
+ schema: {
166
+ type: 'object',
167
+ additionalProperties: false,
168
+ properties: {
169
+ text: { type: 'string' },
170
+ },
171
+ required: ['text'],
172
+ },
173
+ render: (_args, value) => [{ type: 'text', text: value.text }],
174
+ },
175
+ async execute(args, exec) {
176
+ const shell = await resolveShell(exec?.signal)
177
+ const workdir = typeof args.workdir === 'string' && args.workdir.length > 0
178
+ ? args.workdir
179
+ : exec?.agent?.session?.header?.cwd
180
+
181
+ // timeoutMs is a foreground deadline. subprocess only reacts to an abort
182
+ // signal, so we own the classification here: a timeout aborts the tree
183
+ // (SIGTERM -> grace -> SIGKILL), then the error below says WHY.
184
+ const abort = new AbortController()
185
+ let timedOut = false
186
+ const timer = setTimeout(() => {
187
+ timedOut = true
188
+ abort.abort(new Error(`bash timed out after ${timeoutMs}ms`))
189
+ }, timeoutMs)
190
+ const onExecAbort = () => abort.abort(exec?.signal?.reason)
191
+ if (exec?.signal?.aborted) onExecAbort()
192
+ else exec?.signal?.addEventListener('abort', onExecAbort, { once: true })
193
+
194
+ let outcome
195
+ try {
196
+ const handle = ctx.subprocess.spawn({
197
+ argv: [shell, '-c', args.command],
198
+ ...workdir !== undefined ? { cwd: workdir } : {},
199
+ stdio: {
200
+ stdin: 'ignore',
201
+ stdout: { maxBytes: maxOutputBytes },
202
+ stderr: { maxBytes: maxOutputBytes },
203
+ },
204
+ signal: abort.signal,
205
+ graceMs: 3000,
206
+ })
207
+ try {
208
+ outcome = await handle.done
209
+ } catch (error) {
210
+ // A spawn-level failure (bad executable, EPERM) surfaces as a throw,
211
+ // which the runtime turns into an isError result.
212
+ throw new Error(`bash spawn failed: ${String(error)}`)
213
+ }
214
+ let stdout = ''
215
+ let stderr = ''
216
+ try {
217
+ stdout = handle.collected.stdout.readFrom(0).text
218
+ stderr = handle.collected.stderr.readFrom(0).text
219
+ } catch {
220
+ // Collected readers may be unavailable on some backends; tolerate.
221
+ }
222
+ const text = [stdout, stderr].filter((part) => part.length > 0).join('\n')
223
+ const tail = text.length > 0 ? text : `exit code: ${outcome.exitCode} (no output)`
224
+ if (timedOut) {
225
+ throw new Error(`bash timed out after ${timeoutMs}ms${tail ? `\n${tail}` : ''}`)
226
+ }
227
+ if (exec?.signal?.aborted) {
228
+ const reason = exec.signal.reason
229
+ throw new Error(`bash aborted: ${reason instanceof Error ? reason.message : String(reason ?? 'aborted')}`)
230
+ }
231
+ if (outcome.exitCode !== 0) {
232
+ // Non-zero exit is a reported failure, not a throw: the model sees the
233
+ // command output plus the exit code.
234
+ throw new Error(tail)
235
+ }
236
+ return { text: tail }
237
+ } finally {
238
+ clearTimeout(timer)
239
+ exec?.signal?.removeEventListener('abort', onExecAbort)
240
+ }
241
+ },
242
+ })
243
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * instruction-hint — replace `dsh-agent-instructions`' full AGENTS.md/CLAUDE.md
3
+ * injection with a minimal "these files exist" hint.
4
+ *
5
+ * WHY: the full workspace-instruction digest is a large injected block. After
6
+ * the anchored bootstrap promotes, we want the model to KNOW the instruction
7
+ * files exist (so it reads them before acting) without dumping their content
8
+ * into every request. The model reads the files itself via the filesystem
9
+ * tools when it needs them.
10
+ *
11
+ * Behavior:
12
+ * - After the session records its first durable promotion signal
13
+ * (`promoteOn`, default `either`), ONE hint message is injected, listing
14
+ * which instruction files were found:
15
+ * - user-global: `$DSH_HOME/AGENTS.md`
16
+ * - project chain: AGENTS.md / CLAUDE.md / AGENTS.local.md / CLAUDE.local.md
17
+ * walking up from the session cwd to the project root (a directory
18
+ * containing `.git`, or the cwd itself).
19
+ * - The hint is ONCE PER SESSION, DERIVED FROM DURABLE EVENTS: the guard
20
+ * scans the session log for an existing `instruction-hint` message (then
21
+ * O(1)), so a process restart — whose in-memory state starts empty —
22
+ * cannot inject a second copy. A duplicate would collide with the first
23
+ * message's deterministic id (`instruction-hint-<sessionId>`) and break
24
+ * history replay.
25
+ * - The hint instructs the model to READ the files before acting when
26
+ * relevant, without embedding their content.
27
+ * - Files are probed via `ctx.fs` (the host filesystem seam); a missing fs
28
+ * service or an unreadable probe degrades to no hint (never throws).
29
+ * - Pre-promotion requests get NO hint (matches the anchored bootstrap).
30
+ * - Subagents skip the phase wait by default (their first request already
31
+ * counts as promoted); `includeSubagents: true` makes a subagent's own
32
+ * first reply or tool call open the hint — which also keeps the injection
33
+ * out of the context gate's stripped first request (the gate strips
34
+ * non-claimed messages while unpromoted).
35
+ *
36
+ * ROW ORDER: this plugin registers its `agent/pre-step` handler with
37
+ * `prepend: true` and after `context-gate`/`tool-bootstrap`, so it runs
38
+ * inside the gate's outermost strip — but it emits AFTER promotion, when the
39
+ * strip is inactive. The hint source kind is `instruction-hint`, which is
40
+ * not in the gate's claimed-baseline allowlist, so the gate can strip it
41
+ * only while the session is unpromoted (never the intended path).
42
+ */
43
+
44
+ import { readFileSync } from 'node:fs'
45
+ import { createEpochPromotion } from './compaction-epoch.mjs'
46
+ import { booleanOption, createWarnOnce, parsePromoteOn, validateConfig } from './shared.mjs'
47
+
48
+ /** Cordis plugin name used by loader diagnostics. */
49
+ export const name = 'instruction-hint'
50
+
51
+ /** Optional prompt-tool hint text (agents-instruction.txt beside this module). */
52
+ function readAgentsInstructionText() {
53
+ try {
54
+ return readFileSync(new URL('./agents-instruction.txt', import.meta.url), 'utf8').trim()
55
+ } catch {
56
+ return ''
57
+ }
58
+ }
59
+
60
+ /** Candidate file names, in probe order, for the project chain and user-global. */
61
+ const PROJECT_CANDIDATES = ['AGENTS.md', 'CLAUDE.md', 'AGENTS.local.md', 'CLAUDE.local.md']
62
+ const USER_GLOBAL_CANDIDATE = 'AGENTS.md'
63
+
64
+
65
+ /** Every config key this plugin accepts — anything else is a typo. */
66
+ const ALLOWED_KEYS = new Set(['promoteOn', 'includeSubagents'])
67
+
68
+
69
+ /** Find the project root: first ancestor containing any root marker (e.g. .git). */
70
+ async function findProjectRoot(fs, cwd, signal) {
71
+ let current = cwd
72
+ for (;;) {
73
+ for (const marker of ['.git', '.hg', '.svn']) {
74
+ try {
75
+ const target = await fs.resolve(joinPath(current, marker), { cwd, signal })
76
+ const info = await fs.stat(target, signal)
77
+ if (info !== undefined) return current
78
+ } catch {
79
+ // Probe failure = marker absent; continue.
80
+ }
81
+ }
82
+ const parent = parentPath(current)
83
+ if (parent === current || parent.length === 0) return cwd
84
+ current = parent
85
+ }
86
+ }
87
+
88
+ /** List instruction files present in one directory (project candidates). */
89
+ async function presentInDir(fs, dir, candidates, signal) {
90
+ const found = []
91
+ for (const candidate of candidates) {
92
+ try {
93
+ const target = await fs.resolve(joinPath(dir, candidate), { cwd: dir, signal })
94
+ const info = await fs.stat(target, signal)
95
+ if (info !== undefined && info.type === 'file') found.push(candidate)
96
+ } catch {
97
+ // Absent or unreadable — skip.
98
+ }
99
+ }
100
+ return found
101
+ }
102
+
103
+ /** Join one path segment onto a directory (platform-agnostic string join). */
104
+ function joinPath(dir, segment) {
105
+ if (dir.endsWith('/') || dir.endsWith('\\')) return dir + segment
106
+ const sep = dir.includes('\\') ? '\\' : '/'
107
+ return dir + sep + segment
108
+ }
109
+
110
+ /** Parent of an absolute Windows or POSIX path. */
111
+ function parentPath(path) {
112
+ const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
113
+ if (idx <= 0) return path
114
+ const parent = path.slice(0, idx)
115
+ return parent.length === 0 ? path : parent
116
+ }
117
+
118
+ /** Register the post-promotion instruction-hint injector. */
119
+ export function apply(ctx, config) {
120
+ const source = validateConfig(name, config, ALLOWED_KEYS)
121
+ const promoteEvents = parsePromoteOn(name, source.promoteOn)
122
+ const includeSubagents = booleanOption(name, source.includeSubagents, 'includeSubagents', false)
123
+ const agentsInstructionText = readAgentsInstructionText()
124
+ const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
125
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
126
+
127
+ /**
128
+ * Sessions whose hint is already durable in the event log — the
129
+ * restart-safe replacement for an in-memory "already hinted" set. Seeded by
130
+ * a one-time scan, then maintained incrementally through `session/event`.
131
+ */
132
+ const hinted = new Map()
133
+ const hintIsDurable = (session) => {
134
+ const known = hinted.get(session.id)
135
+ if (known !== undefined) return known
136
+ const found = (Array.isArray(session.events) ? session.events : []).some((event) =>
137
+ event.type === 'user/message' && event.data?.source?.kind === 'instruction-hint',
138
+ )
139
+ hinted.set(session.id, found)
140
+ return found
141
+ }
142
+ ctx.on('session/event', (session, event) => {
143
+ if (event.type === 'user/message' && event.data?.source?.kind === 'instruction-hint') {
144
+ hinted.set(session.id, true)
145
+ }
146
+ })
147
+
148
+ const warnOnce = createWarnOnce(ctx, name)
149
+
150
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
151
+ const decision = await next()
152
+ try {
153
+ if (promotion.status(agent).promoted !== true) return decision
154
+ const session = agent.session
155
+ if (session === undefined || hintIsDurable(session)) return decision
156
+ hinted.set(session.id, true)
157
+
158
+ if (agentsInstructionText.length > 0) {
159
+ return {
160
+ ...decision,
161
+ messages: [...decision.messages, {
162
+ id: `instruction-hint-${session.id}`,
163
+ role: 'user',
164
+ content: [{ type: 'text', text: agentsInstructionText }],
165
+ source: { kind: 'instruction-hint', form: 'hint' },
166
+ }],
167
+ }
168
+ }
169
+
170
+ const fs = ctx.get('fs')
171
+ if (fs === undefined) return decision
172
+ const cwd = session.header.cwd ?? process.cwd()
173
+
174
+ const projectFiles = []
175
+ const root = await findProjectRoot(fs, cwd, signal)
176
+ projectFiles.push(...await presentInDir(fs, root, PROJECT_CANDIDATES, signal))
177
+
178
+ const userGlobalFiles = []
179
+ try {
180
+ const dshHome = process.env.DSH_HOME ?? (process.env.USERPROFILE ? `${process.env.USERPROFILE}\\.dsh` : undefined)
181
+ if (dshHome !== undefined) {
182
+ userGlobalFiles.push(...await presentInDir(fs, dshHome, [USER_GLOBAL_CANDIDATE], signal))
183
+ }
184
+ } catch {
185
+ // Unreadable home probe — ignore.
186
+ }
187
+
188
+ const sections = []
189
+ if (projectFiles.length > 0) {
190
+ sections.push(`Workspace instruction files exist: ${projectFiles.join(', ')} (project root: ${root}).`)
191
+ }
192
+ if (userGlobalFiles.length > 0) {
193
+ sections.push(`A user-global instruction file exists: ${USER_GLOBAL_CANDIDATE}.`)
194
+ }
195
+ if (sections.length === 0) return decision
196
+
197
+ const text = [
198
+ ...sections,
199
+ 'Do NOT assume their content. When a task touches this workspace, read the relevant instruction files first and follow them.',
200
+ ].join(' ')
201
+
202
+ return {
203
+ ...decision,
204
+ messages: [...decision.messages, {
205
+ id: `instruction-hint-${session.id}`,
206
+ role: 'user',
207
+ content: [{ type: 'text', text }],
208
+ source: { kind: 'instruction-hint', form: 'hint' },
209
+ }],
210
+ }
211
+ } catch (error) {
212
+ // A hint bug must never hurt the session: skip the hint.
213
+ warnOnce(`${name}: hint injection failed, skipping: ${String((error && error.message) || error)}`)
214
+ return decision
215
+ }
216
+ }, { prepend: true })
217
+ }
@@ -15,6 +15,8 @@
15
15
  * false(默认)时忽略 anchorText,按任务与模型自动选择文本。
16
16
  */
17
17
 
18
+ import { extractText, isDelegated, newMessageId } from './shared.mjs'
19
+
18
20
  /** Cordis 插件名,供 loader 诊断使用。 */
19
21
  export const name = 'near-anchor'
20
22
 
@@ -33,23 +35,8 @@ const ANCHOR_INSPECT = "Start your reasoning with the exact sentence: 'We need t
33
35
  /** 复杂规划类:放行 Let 深度规划路径。 */
34
36
  const ANCHOR_DEEP = "Start your reasoning with the exact sentence: 'Let me think through the design before changing anything.'"
35
37
 
36
- /** 生成消息 id:优先加密随机 id,旧运行时回退到随机串。 */
37
- function newMessageId() {
38
- return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
39
- ? crypto.randomUUID()
40
- : `near-anchor-${Date.now()}-${Math.random().toString(36).slice(2)}`
41
- }
42
-
43
- /** 从 user/message 的 data 中提取纯文本;兼容 data.message 嵌套形状。 */
44
- function extractText(data) {
45
- if (!data) return ''
46
- const payload = data && typeof data.message === 'object' && data.message !== null ? data.message : data
47
- const content = Array.isArray(payload.content) ? payload.content : []
48
- return content.map((block) => (typeof block === 'string' ? block : (block?.text ?? ''))).join(' ').trim()
49
- }
50
-
51
38
  /** 按开关与任务选择锚点:useCustom=true 固定用自定义;false 自动选择。 */
52
- function chooseAnchor(text, modelId, customText, useCustom) {
39
+ function chooseAnchor(text, customText, useCustom) {
53
40
  if (useCustom === true) {
54
41
  return typeof customText === 'string' ? customText.trim() : ''
55
42
  }
@@ -83,7 +70,7 @@ export function apply(ctx, config) {
83
70
  const session = agent.session
84
71
  if (session === undefined || handled.has(session.id) || seenAnchor(session)) return decision
85
72
  // 子代理不注入锚点:让 dsh-mnemon 等结构化 worker 按自己的提示词工作。
86
- if ((session.header?.delegationDepth ?? 0) > 0) return decision
73
+ if (isDelegated(session)) return decision
87
74
 
88
75
  const messages = Array.isArray(decision.messages) ? decision.messages : []
89
76
  // 只锚真实用户消息;插件消息原样保留。
@@ -92,12 +79,12 @@ export function apply(ctx, config) {
92
79
  const taskText = extractText(messages[userIndex])
93
80
  if (taskText.length === 0) return decision
94
81
 
95
- const anchorText = chooseAnchor(taskText, agent.options?.model, customText, useCustom)
82
+ const anchorText = chooseAnchor(taskText, customText, useCustom)
96
83
  if (anchorText.length === 0) return decision
97
84
  handled.add(session.id)
98
85
 
99
86
  const anchor = {
100
- id: newMessageId(),
87
+ id: newMessageId('near-anchor'),
101
88
  role: 'user',
102
89
  content: [{ type: 'text', text: anchorText }],
103
90
  source: {