dsh-plugin-prompt-tool 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/AGENTS.md +35 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +385 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +29 -0
- package/lib/index.mjs +214 -0
- package/lib/preset-core.d.mts +19 -0
- package/lib/preset-core.mjs +97 -0
- package/package.json +88 -0
- package/plan.md +265 -0
- package/preset/preset.yml +3 -0
- package/preset/prompt-injector.mjs +122 -0
- package/preset/turn-anchor.mjs +83 -0
- package/prompt/SKILL.md +49 -0
- package/prompt/references/flash-7013.md +27 -0
- package/prompt/references/pro-8013.md +20 -0
- package/prompt.md +6 -0
- package/vendor/dsh-anchored-standard/LICENSE +22 -0
- package/vendor/dsh-anchored-standard/NOTICE +14 -0
- package/vendor/dsh-anchored-standard/README.md +280 -0
- package/vendor/dsh-anchored-standard/preset/agent.cordis.yml +400 -0
- package/vendor/dsh-anchored-standard/preset/compaction-epoch.mjs +74 -0
- package/vendor/dsh-anchored-standard/preset/custom-bash.mjs +126 -0
- package/vendor/dsh-anchored-standard/preset/dev-tool-search.mjs +129 -0
- package/vendor/dsh-anchored-standard/preset/instruction-hint.mjs +178 -0
- package/vendor/dsh-anchored-standard/preset/preset.yml +3 -0
- package/vendor/dsh-anchored-standard/preset/skill-search.mjs +142 -0
- package/vendor/dsh-anchored-standard/preset/tool-bootstrap.mjs +338 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epoch-aware promotion tracker shared by the bootstrap and baseline-gate
|
|
3
|
+
* plugins of the anchored presets.
|
|
4
|
+
*
|
|
5
|
+
* A compaction rewrites the model-visible surface: the pre-compaction
|
|
6
|
+
* conversation collapses into one synthetic summary message, and the
|
|
7
|
+
* workspace-instruction baseline is re-injected from scratch. The first
|
|
8
|
+
* post-compaction request is therefore a "second first request" — the same
|
|
9
|
+
* first-token conditions the anchored presets exist to control. Promotion is
|
|
10
|
+
* epoch-aware: only a durable promotion signal (`tool/call` and/or
|
|
11
|
+
* `assistant/message`, per the caller's `promoteEvents`) recorded AFTER the
|
|
12
|
+
* last `compaction/end` boundary counts as promoted. Before any compaction
|
|
13
|
+
* the boundary is -1, which preserves the original one-shot semantics.
|
|
14
|
+
*
|
|
15
|
+
* State is memoized per session id and maintained incrementally through
|
|
16
|
+
* `observe()`; a cold session scans its durable log once (so resume and
|
|
17
|
+
* reload reconstruct the same phase), then O(1).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Build one epoch-aware promotion tracker. */
|
|
21
|
+
export function createEpochPromotion(promoteEvents) {
|
|
22
|
+
const promote = new Set(promoteEvents)
|
|
23
|
+
/** sessionId -> { boundary, promoted } */
|
|
24
|
+
const state = new Map()
|
|
25
|
+
|
|
26
|
+
/** Scan a session's durable log from scratch (cold start / resume). */
|
|
27
|
+
const scan = (session) => {
|
|
28
|
+
let boundary = -1
|
|
29
|
+
let promoted = false
|
|
30
|
+
for (const event of session.events) {
|
|
31
|
+
const seq = event.seq ?? 0 // events without a seq are treated as post-boundary
|
|
32
|
+
if (event.type === 'compaction/end') {
|
|
33
|
+
boundary = seq
|
|
34
|
+
promoted = false
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (promote.has(event.type) && seq > boundary) promoted = true
|
|
38
|
+
}
|
|
39
|
+
const entry = { boundary, promoted }
|
|
40
|
+
state.set(session.id, entry)
|
|
41
|
+
return entry
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
/**
|
|
46
|
+
* Current phase of the agent's session.
|
|
47
|
+
* @param agent - the assembly/pre-step agent, or undefined outside an agent.
|
|
48
|
+
* @returns { boundary, promoted } — `boundary` is the last compaction/end
|
|
49
|
+
* seq (-1 before any compaction); `promoted` is true when a durable
|
|
50
|
+
* promotion signal exists after that boundary.
|
|
51
|
+
*/
|
|
52
|
+
status(agent) {
|
|
53
|
+
if (agent === undefined) return { boundary: -1, promoted: true }
|
|
54
|
+
const session = agent.session
|
|
55
|
+
if (session === undefined) return { boundary: -1, promoted: true }
|
|
56
|
+
// Subagents keep the full catalog from their very first request.
|
|
57
|
+
if ((session.header?.delegationDepth ?? 0) > 0) return { boundary: -1, promoted: true }
|
|
58
|
+
return state.get(session.id) ?? scan(session)
|
|
59
|
+
},
|
|
60
|
+
/** Incremental feed: call on every `session/event`. */
|
|
61
|
+
observe(session, event) {
|
|
62
|
+
const entry = state.get(session.id)
|
|
63
|
+
if (entry === undefined) return
|
|
64
|
+
const seq = event.seq ?? 0
|
|
65
|
+
if (event.type === 'compaction/end') {
|
|
66
|
+
state.set(session.id, { boundary: seq, promoted: false })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (promote.has(event.type) && seq > entry.boundary && !entry.promoted) {
|
|
70
|
+
state.set(session.id, { ...entry, promoted: true })
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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`):
|
|
17
|
+
* - explicit absolute path (e.g. `C:\Program Files\Git\bin\bash.exe`), or
|
|
18
|
+
* - `bash` resolved through `ctx.subprocess.resolveExecutable` (PATH lookup).
|
|
19
|
+
*
|
|
20
|
+
* Semantics mirror the official bash tool: `bash -c <command>` in a fresh
|
|
21
|
+
* process, bounded output, non-zero exit reported not thrown. No sandbox
|
|
22
|
+
* confinement on Windows (the sandbox backend is linux-only); the tool
|
|
23
|
+
* description says so. The bootstrap catalog pairs this with
|
|
24
|
+
* `str_replace_editor` (Minimal's two tools).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
28
|
+
export const name = 'custom-bash'
|
|
29
|
+
|
|
30
|
+
/** The subprocess and tools services must exist before this tool can register. */
|
|
31
|
+
export const inject = ['subprocess', 'tools']
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 120000
|
|
34
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64000
|
|
35
|
+
|
|
36
|
+
/** Tool parameter schema for the model-facing command. */
|
|
37
|
+
const commandSchema = {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
command: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'The bash command to execute (`bash -c` string domain).',
|
|
43
|
+
},
|
|
44
|
+
workdir: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'Optional working directory; defaults to the session cwd.',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ['command'],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register the model-facing `bash` tool. */
|
|
54
|
+
export function apply(ctx, config) {
|
|
55
|
+
const bashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : 'bash'
|
|
56
|
+
const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
57
|
+
const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
|
|
58
|
+
|
|
59
|
+
ctx.tools.register({
|
|
60
|
+
name: 'bash',
|
|
61
|
+
description: [
|
|
62
|
+
'Run commands in a bash shell (Git Bash on Windows)',
|
|
63
|
+
'* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
|
|
64
|
+
"* You don't have access to the internet via this tool.",
|
|
65
|
+
'* You do have access to a mirror of common linux and python packages via apt and pip.',
|
|
66
|
+
'* State does NOT persist across command calls: each call runs in a fresh shell.',
|
|
67
|
+
"* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
|
|
68
|
+
'* Please avoid commands that may produce a very large amount of output.',
|
|
69
|
+
'* NOTE: runs without OS sandbox confinement on Windows (no landlock); treat output as untrusted.',
|
|
70
|
+
].join('\n'),
|
|
71
|
+
parameters: commandSchema,
|
|
72
|
+
output: {
|
|
73
|
+
schema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
properties: {
|
|
77
|
+
text: { type: 'string' },
|
|
78
|
+
},
|
|
79
|
+
required: ['text'],
|
|
80
|
+
},
|
|
81
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
82
|
+
},
|
|
83
|
+
async execute(args, exec) {
|
|
84
|
+
const shell = await ctx.subprocess.resolveExecutable(bashPath, undefined, exec?.signal)
|
|
85
|
+
const workdir = typeof args.workdir === 'string' && args.workdir.length > 0
|
|
86
|
+
? args.workdir
|
|
87
|
+
: exec?.agent?.session?.header?.cwd
|
|
88
|
+
const signal = exec?.signal
|
|
89
|
+
const handle = ctx.subprocess.spawn({
|
|
90
|
+
argv: [shell, '-c', args.command],
|
|
91
|
+
...workdir !== undefined ? { cwd: workdir } : {},
|
|
92
|
+
stdio: {
|
|
93
|
+
stdin: 'ignore',
|
|
94
|
+
stdout: { maxBytes: maxOutputBytes },
|
|
95
|
+
stderr: { maxBytes: maxOutputBytes },
|
|
96
|
+
},
|
|
97
|
+
...signal !== undefined ? { signal } : {},
|
|
98
|
+
graceMs: 3000,
|
|
99
|
+
})
|
|
100
|
+
let outcome
|
|
101
|
+
try {
|
|
102
|
+
outcome = await handle.done
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// A spawn-level failure (bad executable, EPERM) surfaces as a throw,
|
|
105
|
+
// which the runtime turns into an isError result.
|
|
106
|
+
throw new Error(`bash spawn failed: ${String(error)}`)
|
|
107
|
+
}
|
|
108
|
+
let stdout = ''
|
|
109
|
+
let stderr = ''
|
|
110
|
+
try {
|
|
111
|
+
stdout = handle.collected.stdout.readFrom(0).text
|
|
112
|
+
stderr = handle.collected.stderr.readFrom(0).text
|
|
113
|
+
} catch {
|
|
114
|
+
// Collected readers may be unavailable on some backends; tolerate.
|
|
115
|
+
}
|
|
116
|
+
const text = [stdout, stderr].filter((part) => part.length > 0).join('\n')
|
|
117
|
+
const tail = text.length > 0 ? text : `exit code: ${outcome.exitCode} (no output)`
|
|
118
|
+
if (outcome.exitCode !== 0) {
|
|
119
|
+
// Non-zero exit is a reported failure, not a throw: the model sees the
|
|
120
|
+
// command output plus the exit code.
|
|
121
|
+
throw new Error(tail)
|
|
122
|
+
}
|
|
123
|
+
return { text: tail }
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-tool-search — on-demand tool discovery and unlock, the tool-search
|
|
3
|
+
* pattern for the anchored preset.
|
|
4
|
+
*
|
|
5
|
+
* The promoted phase keeps only a minimal resident set (shell +
|
|
6
|
+
* str_replace_editor + the discovery tools) instead of dumping the whole
|
|
7
|
+
* Standard catalog at once. This plugin registers ONE small tool:
|
|
8
|
+
*
|
|
9
|
+
* - `dev_tool_search` — search the FULL assembled catalog by keyword and
|
|
10
|
+
* return matching tool names with short descriptions; optionally unlock
|
|
11
|
+
* tools by exact name (array `toolNames`). Unlocked names are recorded as
|
|
12
|
+
* durable `tool/call` arguments, and tool-bootstrap.mjs's assemble filter
|
|
13
|
+
* exposes them from the next request on (resume-safe).
|
|
14
|
+
*
|
|
15
|
+
* The tool description is deliberately an INDEX of what the minimal resident
|
|
16
|
+
* set cannot do: the model should reach for dev_tool_search the moment a task
|
|
17
|
+
* needs internet, delegation, workflows, goals, images, background jobs, or
|
|
18
|
+
* multi-agent coordination — not try to work around them with bash.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
22
|
+
export const name = 'dev-tool-search'
|
|
23
|
+
|
|
24
|
+
/** The tools registry must exist before this tool can register. */
|
|
25
|
+
export const inject = ['tools']
|
|
26
|
+
|
|
27
|
+
const MAX_RESULTS = 25
|
|
28
|
+
|
|
29
|
+
/** Minimal JSON schema compiler for tool parameters (zero dependencies). */
|
|
30
|
+
function toJsonSchema(spec) {
|
|
31
|
+
const properties = {}
|
|
32
|
+
const required = []
|
|
33
|
+
for (const [key, meta] of Object.entries(spec || {})) {
|
|
34
|
+
const prop = { type: meta.type }
|
|
35
|
+
if (meta.description) prop.description = meta.description
|
|
36
|
+
properties[key] = prop
|
|
37
|
+
if (meta.required) required.push(key)
|
|
38
|
+
}
|
|
39
|
+
return { type: 'object', properties, required, additionalProperties: false }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The capability index: resident minimal tools (bash / str_replace_editor /
|
|
44
|
+
* skill_search / skill_load) cannot cover these, so the model must search
|
|
45
|
+
* and unlock them on demand. Kept in the description so the model KNOWS what
|
|
46
|
+
* exists without a full catalog dump.
|
|
47
|
+
*/
|
|
48
|
+
const UNLOCKABLE_INDEX = [
|
|
49
|
+
'web_search — internet search and web retrieval',
|
|
50
|
+
'subagent / subagent_fork — delegate work to sub-agents',
|
|
51
|
+
'workflow — run multi-agent workflow scripts',
|
|
52
|
+
'ralph — fresh-agent iterative loop',
|
|
53
|
+
'create_goal / get_goal / update_goal — long-running goals',
|
|
54
|
+
'read_image — read image files',
|
|
55
|
+
'job_list / job_output / job_kill — background jobs',
|
|
56
|
+
'interrupt_agent / send_message / list_agents — multi-agent control',
|
|
57
|
+
'todo_write — task tracking',
|
|
58
|
+
'ask_user_question — ask the user',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** Register the model-facing `dev_tool_search` tool. */
|
|
62
|
+
export function apply(ctx) {
|
|
63
|
+
ctx.tools.register({
|
|
64
|
+
name: 'dev_tool_search',
|
|
65
|
+
description: [
|
|
66
|
+
'Discover and unlock tools that are NOT currently available.',
|
|
67
|
+
'',
|
|
68
|
+
'This session starts with a minimal resident set: bash, str_replace_editor, skill_search, skill_load. Everything else is unlocked on demand through this tool.',
|
|
69
|
+
'',
|
|
70
|
+
'If the current task needs any of the following, call dev_tool_search FIRST — do not try to work around them with bash:',
|
|
71
|
+
...UNLOCKABLE_INDEX.map((line) => `- ${line}`),
|
|
72
|
+
'',
|
|
73
|
+
'Usage: pass `query` to search the catalog (returns matching tool names + descriptions), then pass `toolNames` with exact names to unlock them. Unlocked tools appear from the next request on and stay unlocked for the session.',
|
|
74
|
+
].join('\n'),
|
|
75
|
+
parameters: toJsonSchema({
|
|
76
|
+
query: { type: 'string', required: false, description: 'search keywords (e.g. "web", "subagent")' },
|
|
77
|
+
toolNames: { type: 'array', required: false, description: 'exact tool names to unlock', items: { type: 'string' } },
|
|
78
|
+
}),
|
|
79
|
+
output: {
|
|
80
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
81
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
82
|
+
},
|
|
83
|
+
async execute(args, exec) {
|
|
84
|
+
const query = typeof args.query === 'string' ? args.query.trim() : ''
|
|
85
|
+
const unlock = Array.isArray(args.toolNames) ? args.toolNames.filter((name) => typeof name === 'string' && name.length > 0) : []
|
|
86
|
+
|
|
87
|
+
const lines = []
|
|
88
|
+
if (unlock.length > 0) {
|
|
89
|
+
lines.push(`Unlocked for the next request: ${unlock.join(', ')}`)
|
|
90
|
+
}
|
|
91
|
+
if (query.length === 0 && unlock.length === 0) {
|
|
92
|
+
lines.push('Provide `query` to search the catalog, or `toolNames` to unlock tools.')
|
|
93
|
+
return { text: lines.join('\n') }
|
|
94
|
+
}
|
|
95
|
+
if (query.length === 0) {
|
|
96
|
+
return { text: lines.join('\n') || 'Nothing to do.' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// The executing agent IS the viewing scope: preset tools register into
|
|
101
|
+
// the agent-scope layer of the tools registry, and schemas() with no
|
|
102
|
+
// scope only sees the global layer — every preset-provided tool would
|
|
103
|
+
// be invisible to keyword search (issue #24). Same pattern as the
|
|
104
|
+
// harness's own code mode (`registry.schemas(exec.agent)`).
|
|
105
|
+
const schemas = ctx.tools.schemas(exec?.agent)
|
|
106
|
+
const wanted = query.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
|
|
107
|
+
const matches = schemas
|
|
108
|
+
.filter((schema) => {
|
|
109
|
+
const haystack = `${schema.name} ${schema.description ?? ''}`.toLowerCase()
|
|
110
|
+
return wanted.every((token) => haystack.includes(token))
|
|
111
|
+
})
|
|
112
|
+
.slice(0, MAX_RESULTS)
|
|
113
|
+
if (matches.length === 0) {
|
|
114
|
+
lines.push(`No tools match "${query}".`)
|
|
115
|
+
} else {
|
|
116
|
+
lines.push(`Matching tools (${matches.length}):`)
|
|
117
|
+
for (const schema of matches) {
|
|
118
|
+
const desc = (schema.description || '').split('\n')[0].slice(0, 90)
|
|
119
|
+
lines.push(`- ${schema.name}: ${desc}`)
|
|
120
|
+
}
|
|
121
|
+
lines.push('Unlock with dev_tool_search({"toolNames": ["<exact name>"]}).')
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
lines.push(`catalog search unavailable: ${String((error && error.message) || error)}`)
|
|
125
|
+
}
|
|
126
|
+
return { text: lines.join('\n') }
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
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 (once per
|
|
14
|
+
* session — durable event scan, resume-safe), listing which instruction
|
|
15
|
+
* files were found:
|
|
16
|
+
* - user-global: `$DSH_HOME/AGENTS.md`
|
|
17
|
+
* - project chain: AGENTS.md / CLAUDE.md / AGENTS.local.md / CLAUDE.local.md
|
|
18
|
+
* walking up from the session cwd to the project root (a directory
|
|
19
|
+
* containing `.git`, or the cwd itself).
|
|
20
|
+
* - The hint instructs the model to READ the files before acting when
|
|
21
|
+
* relevant, without embedding their content.
|
|
22
|
+
* - Files are probed via `ctx.fs` (the host filesystem seam); a missing fs
|
|
23
|
+
* service or an unreadable probe degrades to no hint (never throws).
|
|
24
|
+
* - Pre-promotion requests get NO hint (matches the anchored bootstrap).
|
|
25
|
+
*
|
|
26
|
+
* ROW ORDER: this plugin registers its `agent/pre-step` handler with
|
|
27
|
+
* `prepend: true` and after `tool-bootstrap`, so it runs inside the
|
|
28
|
+
* bootstrap's outermost strip — but it emits AFTER promotion, when the strip
|
|
29
|
+
* is inactive. The hint source kind is `instruction-hint`, which is NOT in
|
|
30
|
+
* `suppressedContextSources`, so it is never stripped.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createEpochPromotion } from './compaction-epoch.mjs'
|
|
34
|
+
|
|
35
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
36
|
+
export const name = 'instruction-hint'
|
|
37
|
+
|
|
38
|
+
/** Durable session event types that count as a promotion signal per mode. */
|
|
39
|
+
const PROMOTE_EVENTS = {
|
|
40
|
+
'tool-call': ['tool/call'],
|
|
41
|
+
'assistant-message': ['assistant/message'],
|
|
42
|
+
either: ['tool/call', 'assistant/message'],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Candidate file names, in probe order, for the project chain and user-global. */
|
|
46
|
+
const PROJECT_CANDIDATES = ['AGENTS.md', 'CLAUDE.md', 'AGENTS.local.md', 'CLAUDE.local.md']
|
|
47
|
+
const USER_GLOBAL_CANDIDATE = 'AGENTS.md'
|
|
48
|
+
|
|
49
|
+
function parsePromoteOn(value) {
|
|
50
|
+
if (value === undefined || value === 'either') return PROMOTE_EVENTS.either
|
|
51
|
+
if (value === 'tool-call' || value === 'assistant-message') return PROMOTE_EVENTS[value]
|
|
52
|
+
throw new TypeError(`${name}: promoteOn must be one of "tool-call", "assistant-message", "either"; got ${JSON.stringify(value)}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Find the project root: first ancestor containing any root marker (e.g. .git). */
|
|
56
|
+
async function findProjectRoot(fs, cwd, signal) {
|
|
57
|
+
let current = cwd
|
|
58
|
+
for (;;) {
|
|
59
|
+
for (const marker of ['.git', '.hg', '.svn']) {
|
|
60
|
+
try {
|
|
61
|
+
const target = await fs.resolve(joinPath(current, marker), { cwd, signal })
|
|
62
|
+
const info = await fs.stat(target, signal)
|
|
63
|
+
if (info !== undefined) return current
|
|
64
|
+
} catch {
|
|
65
|
+
// Probe failure = marker absent; continue.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const parent = parentPath(current)
|
|
69
|
+
if (parent === current || parent.length === 0) return cwd
|
|
70
|
+
current = parent
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** List instruction files present in one directory (project candidates). */
|
|
75
|
+
async function presentInDir(fs, dir, candidates, signal) {
|
|
76
|
+
const found = []
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
const target = await fs.resolve(joinPath(dir, candidate), { cwd: dir, signal })
|
|
80
|
+
const info = await fs.stat(target, signal)
|
|
81
|
+
if (info !== undefined && info.type === 'file') found.push(candidate)
|
|
82
|
+
} catch {
|
|
83
|
+
// Absent or unreadable — skip.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return found
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Join one path segment onto a directory (platform-agnostic string join). */
|
|
90
|
+
function joinPath(dir, segment) {
|
|
91
|
+
if (dir.endsWith('/') || dir.endsWith('\\')) return dir + segment
|
|
92
|
+
const sep = dir.includes('\\') ? '\\' : '/'
|
|
93
|
+
return dir + sep + segment
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Parent of an absolute Windows or POSIX path. */
|
|
97
|
+
function parentPath(path) {
|
|
98
|
+
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
99
|
+
if (idx <= 0) return path
|
|
100
|
+
const parent = path.slice(0, idx)
|
|
101
|
+
return parent.length === 0 ? path : parent
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register the post-promotion instruction-hint injector. */
|
|
105
|
+
export function apply(ctx, config) {
|
|
106
|
+
const promoteEvents = parsePromoteOn(config.promoteOn)
|
|
107
|
+
const promotion = createEpochPromotion(promoteEvents)
|
|
108
|
+
ctx.on('session/event', (session, event) => promotion.observe(session, event))
|
|
109
|
+
|
|
110
|
+
/** Sessions that already received the hint. */
|
|
111
|
+
const hinted = new Set()
|
|
112
|
+
let warned = false
|
|
113
|
+
const warnOnce = (message) => {
|
|
114
|
+
if (warned) return
|
|
115
|
+
warned = true
|
|
116
|
+
try {
|
|
117
|
+
ctx.logger.warn(message)
|
|
118
|
+
} catch {
|
|
119
|
+
// Logger unavailable — the guard exists only to avoid spamming.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
124
|
+
const decision = await next()
|
|
125
|
+
try {
|
|
126
|
+
if (promotion.status(agent).promoted !== true) return decision
|
|
127
|
+
const session = agent.session
|
|
128
|
+
if (session === undefined || hinted.has(session.id)) return decision
|
|
129
|
+
hinted.add(session.id)
|
|
130
|
+
|
|
131
|
+
const fs = ctx.get('fs')
|
|
132
|
+
if (fs === undefined) return decision
|
|
133
|
+
const cwd = session.header.cwd ?? process.cwd()
|
|
134
|
+
|
|
135
|
+
const projectFiles = []
|
|
136
|
+
const root = await findProjectRoot(fs, cwd, signal)
|
|
137
|
+
projectFiles.push(...await presentInDir(fs, root, PROJECT_CANDIDATES, signal))
|
|
138
|
+
|
|
139
|
+
const userGlobalFiles = []
|
|
140
|
+
try {
|
|
141
|
+
const dshHome = process.env.DSH_HOME ?? (process.env.USERPROFILE ? `${process.env.USERPROFILE}\\.dsh` : undefined)
|
|
142
|
+
if (dshHome !== undefined) {
|
|
143
|
+
userGlobalFiles.push(...await presentInDir(fs, dshHome, [USER_GLOBAL_CANDIDATE], signal))
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Unreadable home probe — ignore.
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sections = []
|
|
150
|
+
if (projectFiles.length > 0) {
|
|
151
|
+
sections.push(`Workspace instruction files exist: ${projectFiles.join(', ')} (project root: ${root}).`)
|
|
152
|
+
}
|
|
153
|
+
if (userGlobalFiles.length > 0) {
|
|
154
|
+
sections.push(`A user-global instruction file exists: ${USER_GLOBAL_CANDIDATE}.`)
|
|
155
|
+
}
|
|
156
|
+
if (sections.length === 0) return decision
|
|
157
|
+
|
|
158
|
+
const text = [
|
|
159
|
+
...sections,
|
|
160
|
+
'Do NOT assume their content. When a task touches this workspace, read the relevant instruction files first and follow them.',
|
|
161
|
+
].join(' ')
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...decision,
|
|
165
|
+
messages: [...decision.messages, {
|
|
166
|
+
id: `instruction-hint-${session.id}`,
|
|
167
|
+
role: 'user',
|
|
168
|
+
content: [{ type: 'text', text }],
|
|
169
|
+
source: { kind: 'instruction-hint', form: 'hint' },
|
|
170
|
+
}],
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// A hint bug must never hurt the session: skip the hint.
|
|
174
|
+
warnOnce(`${name}: hint injection failed, skipping: ${String((error && error.message) || error)}`)
|
|
175
|
+
return decision
|
|
176
|
+
}
|
|
177
|
+
}, { prepend: true })
|
|
178
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
name: Anchored Standard (experimental)
|
|
2
|
+
description: Bootstrap with the Minimal preset's real tool pair (persistent bash + str_replace_editor) and no auto-injected workspace or skill context, then expose the full Standard catalog after the first durable tool call or reply.
|
|
3
|
+
order: 5
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-search — on-demand skill discovery and loading, replacing
|
|
3
|
+
* `dsh-tool-skill`'s full-catalog injection.
|
|
4
|
+
*
|
|
5
|
+
* WHY: the available-skills reminder (`<available_skills>`, ~9KB with many
|
|
6
|
+
* skills) is injected into the first step by dsh-tool-skill and again after
|
|
7
|
+
* every promotion/compaction. That large injected block perturbs the
|
|
8
|
+
* trajectory (issue #6: 0/9 anchored with the catalog present vs ~81%
|
|
9
|
+
* without). We remove the catalog injection entirely and expose two small
|
|
10
|
+
* tools instead — the Claude tool-search pattern:
|
|
11
|
+
*
|
|
12
|
+
* - `skill_search` — list skills whose name/description match a query
|
|
13
|
+
* (summaries only, bounded; no bodies). The model discovers what exists
|
|
14
|
+
* without a 9KB dump.
|
|
15
|
+
* - `skill_load` — load ONE skill's full instructions by exact name and
|
|
16
|
+
* inject them for the NEXT request via `agent.inject` (the non-waking
|
|
17
|
+
* next-step inbox). The model (or the user) calls this only when the
|
|
18
|
+
* skill is actually needed.
|
|
19
|
+
*
|
|
20
|
+
* Discovery reads `ctx.skills` scoped to the calling agent, exactly like
|
|
21
|
+
* dsh-tool-skill. If skills are unavailable the tools answer with a short
|
|
22
|
+
* message instead of throwing.
|
|
23
|
+
*
|
|
24
|
+
* NOTE: this plugin REPLACES the `dsh-tool-skill` row in the composition —
|
|
25
|
+
* the composition must NOT mount both, or the catalog injection returns.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
29
|
+
export const name = 'skill-search'
|
|
30
|
+
|
|
31
|
+
/** The agent, tools, and skills services must exist before these tools can register. */
|
|
32
|
+
export const inject = ['agents', 'tools', 'skills']
|
|
33
|
+
|
|
34
|
+
const MAX_RESULTS = 20
|
|
35
|
+
|
|
36
|
+
/** Minimal JSON schema compiler for tool parameters (zero dependencies). */
|
|
37
|
+
function toJsonSchema(spec) {
|
|
38
|
+
const properties = {}
|
|
39
|
+
const required = []
|
|
40
|
+
for (const [key, meta] of Object.entries(spec || {})) {
|
|
41
|
+
const prop = { type: meta.type }
|
|
42
|
+
if (meta.description) prop.description = meta.description
|
|
43
|
+
properties[key] = prop
|
|
44
|
+
if (meta.required) required.push(key)
|
|
45
|
+
}
|
|
46
|
+
return { type: 'object', properties, required, additionalProperties: false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register the two on-demand skill tools. */
|
|
50
|
+
export function apply(ctx) {
|
|
51
|
+
/** Normalize a query into lowercase tokens for simple substring matching. */
|
|
52
|
+
const tokens = (text) => (text || '').toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean)
|
|
53
|
+
|
|
54
|
+
ctx.tools.register({
|
|
55
|
+
name: 'skill_search',
|
|
56
|
+
description: 'Search the available skills by keyword and return matching skill names with short descriptions. This session keeps NO skill catalog in the prompt — if a task looks like it matches a skill (document conversion, image processing, game reviews, markdown, PDF, spreadsheets, …), call skill_search FIRST to find it, then skill_load to activate it. Do NOT assume skill names from memory.',
|
|
57
|
+
parameters: toJsonSchema({
|
|
58
|
+
query: { type: 'string', required: true, description: 'search keywords (e.g. "pdf", "obsidian", "game review")' },
|
|
59
|
+
}),
|
|
60
|
+
output: {
|
|
61
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
62
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
63
|
+
},
|
|
64
|
+
async execute(args, exec) {
|
|
65
|
+
const wanted = tokens(args.query)
|
|
66
|
+
const scope = exec?.agent ?? ctx
|
|
67
|
+
try {
|
|
68
|
+
const all = await ctx.skills.list({
|
|
69
|
+
scope,
|
|
70
|
+
cwd: exec?.agent?.session?.header?.cwd,
|
|
71
|
+
signal: exec?.signal,
|
|
72
|
+
})
|
|
73
|
+
const matches = all.filter((skill) => {
|
|
74
|
+
if (wanted.length === 0) return true
|
|
75
|
+
const haystack = tokens(`${skill.name} ${skill.description ?? ''} ${skill.whenToUse ?? ''}`).join(' ')
|
|
76
|
+
return wanted.every((token) => haystack.includes(token))
|
|
77
|
+
})
|
|
78
|
+
const head = matches.slice(0, MAX_RESULTS)
|
|
79
|
+
const lines = head.map((skill) => {
|
|
80
|
+
const desc = (skill.description || '').split('\n')[0]
|
|
81
|
+
return `- ${skill.name}: ${desc}`
|
|
82
|
+
})
|
|
83
|
+
if (lines.length === 0) return { text: `No skills match "${args.query}". Use skill_search with other keywords.` }
|
|
84
|
+
const extra = matches.length > MAX_RESULTS ? `\n…(${matches.length - MAX_RESULTS} more)` : ''
|
|
85
|
+
return { text: `Matching skills (${matches.length}):\n${lines.join('\n')}${extra}\n\nLoad one with skill_load (exact name).` }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { text: `skill_search unavailable: ${String((error && error.message) || error)}` }
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
ctx.tools.register({
|
|
93
|
+
name: 'skill_load',
|
|
94
|
+
description: 'Load the full instructions of ONE skill by its exact name (from skill_search results) and inject them for the next request. Call this before acting on a task that matches the skill.',
|
|
95
|
+
parameters: toJsonSchema({
|
|
96
|
+
name: { type: 'string', required: true, description: 'exact skill name (kebab-case, from skill_search)' },
|
|
97
|
+
}),
|
|
98
|
+
output: {
|
|
99
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
100
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
101
|
+
},
|
|
102
|
+
async execute(args, exec) {
|
|
103
|
+
try {
|
|
104
|
+
const agent = exec?.agent
|
|
105
|
+
if (agent === undefined) return { text: 'skill_load requires an agent context.' }
|
|
106
|
+
const skill = await ctx.skills.get(args.name, {
|
|
107
|
+
scope: agent,
|
|
108
|
+
cwd: agent.session.header.cwd,
|
|
109
|
+
signal: exec?.signal,
|
|
110
|
+
})
|
|
111
|
+
if (skill === undefined) {
|
|
112
|
+
return { text: `No skill named "${args.name}". Run skill_search to list available skills.` }
|
|
113
|
+
}
|
|
114
|
+
const body = extractSkillBody(skill)
|
|
115
|
+
if (body.length === 0) {
|
|
116
|
+
return { text: `Skill "${args.name}" has no loadable body.` }
|
|
117
|
+
}
|
|
118
|
+
// Queue the skill content as a non-waking next-step context message,
|
|
119
|
+
// exactly like dsh-tool-skill's invocation injection.
|
|
120
|
+
agent.inject({
|
|
121
|
+
id: `skill-load-${args.name}-${Date.now()}`,
|
|
122
|
+
role: 'user',
|
|
123
|
+
content: [{ type: 'text', text: body }],
|
|
124
|
+
source: { kind: 'skill-invocation', name: args.name, form: 'instructions' },
|
|
125
|
+
})
|
|
126
|
+
return { text: `Skill "${args.name}" loaded; its instructions will be injected for the next request.` }
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { text: `skill_load failed: ${String((error && error.message) || error)}` }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Extract the model-facing body of a loaded skill definition. */
|
|
135
|
+
function extractSkillBody(skill) {
|
|
136
|
+
const content = skill?.content ?? skill?.instructions ?? skill?.body
|
|
137
|
+
if (typeof content === 'string') return content
|
|
138
|
+
if (Array.isArray(content)) {
|
|
139
|
+
return content.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join('\n')
|
|
140
|
+
}
|
|
141
|
+
return ''
|
|
142
|
+
}
|