dsh-plugin-prompt-tool 0.1.4 → 0.2.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.
@@ -10,24 +10,35 @@
10
10
  *
11
11
  * Behavior:
12
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:
13
+ * (`promoteOn`, default `either`), ONE hint message is injected, listing
14
+ * which instruction files were found:
16
15
  * - user-global: `$DSH_HOME/AGENTS.md`
17
16
  * - project chain: AGENTS.md / CLAUDE.md / AGENTS.local.md / CLAUDE.local.md
18
17
  * walking up from the session cwd to the project root (a directory
19
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.
20
25
  * - The hint instructs the model to READ the files before acting when
21
26
  * relevant, without embedding their content.
22
27
  * - Files are probed via `ctx.fs` (the host filesystem seam); a missing fs
23
28
  * service or an unreadable probe degrades to no hint (never throws).
24
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).
25
35
  *
26
36
  * 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.
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).
31
42
  */
32
43
 
33
44
  import { createEpochPromotion } from './compaction-epoch.mjs'
@@ -52,6 +63,18 @@ function parsePromoteOn(value) {
52
63
  throw new TypeError(`${name}: promoteOn must be one of "tool-call", "assistant-message", "either"; got ${JSON.stringify(value)}`)
53
64
  }
54
65
 
66
+ /** Every config key this plugin accepts — anything else is a typo. */
67
+ const ALLOWED_KEYS = new Set(['promoteOn', 'includeSubagents'])
68
+
69
+ /** Validate an optional boolean flag with a default. */
70
+ function booleanOption(value, field, fallback) {
71
+ if (value === undefined) return fallback
72
+ if (typeof value !== 'boolean') {
73
+ throw new TypeError(`${name}: ${field} must be a boolean`)
74
+ }
75
+ return value
76
+ }
77
+
55
78
  /** Find the project root: first ancestor containing any root marker (e.g. .git). */
56
79
  async function findProjectRoot(fs, cwd, signal) {
57
80
  let current = cwd
@@ -103,12 +126,42 @@ function parentPath(path) {
103
126
 
104
127
  /** Register the post-promotion instruction-hint injector. */
105
128
  export function apply(ctx, config) {
106
- const promoteEvents = parsePromoteOn(config.promoteOn)
107
- const promotion = createEpochPromotion(promoteEvents)
129
+ const source = config === undefined ? {} : config
130
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) {
131
+ throw new TypeError(`${name}: config must be an object`)
132
+ }
133
+ const unknown = Object.keys(source).filter((key) => !ALLOWED_KEYS.has(key))
134
+ if (unknown.length > 0) {
135
+ throw new TypeError(
136
+ `${name}: unknown config key(s) ${unknown.join(', ')} — allowed keys: ${[...ALLOWED_KEYS].sort().join(', ')}`,
137
+ )
138
+ }
139
+ const promoteEvents = parsePromoteOn(source.promoteOn)
140
+ const includeSubagents = booleanOption(source.includeSubagents, 'includeSubagents', false)
141
+ const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
108
142
  ctx.on('session/event', (session, event) => promotion.observe(session, event))
109
143
 
110
- /** Sessions that already received the hint. */
111
- const hinted = new Set()
144
+ /**
145
+ * Sessions whose hint is already durable in the event log — the
146
+ * restart-safe replacement for an in-memory "already hinted" set. Seeded by
147
+ * a one-time scan, then maintained incrementally through `session/event`.
148
+ */
149
+ const hinted = new Map()
150
+ const hintIsDurable = (session) => {
151
+ const known = hinted.get(session.id)
152
+ if (known !== undefined) return known
153
+ const found = (Array.isArray(session.events) ? session.events : []).some((event) =>
154
+ event.type === 'user/message' && event.data?.source?.kind === 'instruction-hint',
155
+ )
156
+ hinted.set(session.id, found)
157
+ return found
158
+ }
159
+ ctx.on('session/event', (session, event) => {
160
+ if (event.type === 'user/message' && event.data?.source?.kind === 'instruction-hint') {
161
+ hinted.set(session.id, true)
162
+ }
163
+ })
164
+
112
165
  let warned = false
113
166
  const warnOnce = (message) => {
114
167
  if (warned) return
@@ -125,8 +178,8 @@ export function apply(ctx, config) {
125
178
  try {
126
179
  if (promotion.status(agent).promoted !== true) return decision
127
180
  const session = agent.session
128
- if (session === undefined || hinted.has(session.id)) return decision
129
- hinted.add(session.id)
181
+ if (session === undefined || hintIsDurable(session)) return decision
182
+ hinted.set(session.id, true)
130
183
 
131
184
  const fs = ctx.get('fs')
132
185
  if (fs === undefined) return decision
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Anchored tool bootstrap — keep the FIRST model request on the Minimal
3
- * preset's REAL tool schema (persistent `bash` + `str_replace_editor`), free
4
- * of auto-injected workspace/skill context, then narrow the catalog to a
5
- * minimal RESIDENT set once the session has produced its first durable
6
- * promotion signal.
3
+ * preset's REAL tool schema (persistent `bash` + `str_replace_editor`), then
4
+ * narrow the catalog to a minimal RESIDENT set once the session has produced
5
+ * its first durable promotion signal. Injected-context control lives in the
6
+ * companion `context-gate` plugin, not here.
7
7
  *
8
8
  * The phase is derived from durable session events, so resume and reload
9
9
  * preserve it. By default (`promoteOn: 'either'`) a session promotes after the
@@ -39,18 +39,19 @@
39
39
  * promotion — the next request's seed proposal carries the previous
40
40
  * header's maxTokens forward, so the release must be explicit.
41
41
  *
42
- * 3. Injected reminders. dsh-agent-instructions and dsh-tool-skill inject
43
- * workspace instructions (AGENTS.md) and the skill catalog into the first
44
- * step as user messages whenever such content exists. With the skill
45
- * catalog present the anchor did not reproduce at all (0/9); without it
46
- * the same request reproduces at ~81%. Both message kinds are therefore
47
- * stripped during bootstrap and allowed again after promotion. The
48
- * stripped set is configurable via `suppressedContextSources` (default
49
- * `['skill-catalog', 'agent-instructions']`); an explicitly empty array
50
- * disables the context filter while keeping the tool bootstrap. A
51
- * user-initiated skill gesture (`skill-invocation`) is NOT in the default
52
- * set: it is not an automatic injection, and stripping it would lose the
53
- * skill content once the gesture scrolls out of the per-step claim.
42
+ * 3. Injected context is NOT this plugin's concern: the companion
43
+ * `context-gate` plugin (shared/context-gate.mjs, mounted as the FIRST
44
+ * row) owns the unified injection control — runtime-context suppression
45
+ * on the assembly path and a claimed-baseline deny on the pre-step
46
+ * waterfall, both keyed to the same epoch-aware promotion phase. Mount it
47
+ * separately for context control alone; this file narrows only the tool
48
+ * catalog (plus the optional output cap below).
49
+ *
50
+ * SUBAGENTS: by default subagents (delegationDepth > 0) are always promoted
51
+ * (resident catalog from their first request). `includeSubagents: true`
52
+ * makes them follow the same bootstrap phase — their first request also sees
53
+ * the bootstrap pair, and their own first reply or tool call promotes them.
54
+ * Keep this flag in sync with the context-gate row's flag.
54
55
  *
55
56
  * POST-PROMOTION RESIDENT SET (local addition, user-measured): the promoted
56
57
  * phase does NOT dump the whole Standard catalog at once — that dump pulls
@@ -76,15 +77,14 @@
76
77
  * Robustness:
77
78
  * - Promotion decisions are memoized per session id for this process; the
78
79
  * durable event scan runs once per session per process, then O(1).
79
- * - Subagents (delegationDepth > 0) are always promoted (resident catalog).
80
+ * - Subagents (delegationDepth > 0) are always promoted (resident catalog)
81
+ * unless `includeSubagents: true`.
80
82
  * - A missing bootstrap tool degrades to the full catalog with a one-time
81
83
  * warning instead of throwing, so a composition drift can never brick
82
84
  * every request of a session.
83
- * - The pre-step context filter degrades to "keep everything" on failure:
84
- * a filter bug must never eat the user's context.
85
- * - Invalid config (bad tool lists, unknown `promoteOn`, malformed
86
- * `suppressedContextSources`, non-positive `bootstrapMaxTokens`) fails at
87
- * apply time, i.e. at preset mount, where it is visible and fixable.
85
+ * - Invalid config (bad tool lists, unknown `promoteOn`, malformed flags,
86
+ * non-positive `bootstrapMaxTokens`) fails at apply time, i.e. at preset
87
+ * mount, where it is visible and fixable.
88
88
  */
89
89
 
90
90
  import { createEpochPromotion } from './compaction-epoch.mjs'
@@ -94,14 +94,11 @@ export const name = 'anchored-tool-bootstrap'
94
94
 
95
95
  /**
96
96
  * Deliberately NO inject list: the listeners only touch services at event
97
- * time. Applying without an inject — combined with this row being FIRST in
98
- * agent.cordis.yml — registers the plugin before dsh-agent-instructions and
99
- * dsh-tool-skill, and waterfall after-next transforms apply in reverse
100
- * registration order, so the first-request strip below is the LAST transform.
101
- * With an inject here those plugins register first and re-inject their
102
- * messages after the strip. The pre-step listener additionally registers with
103
- * `prepend: true` so the strip stays the outermost transform even against
104
- * host-plane listeners and future row reordering.
97
+ * time. Keep this row right AFTER the context-gate row in agent.cordis.yml:
98
+ * waterfall after-next transforms apply in reverse registration order, so the
99
+ * tool filter here must register before any plugin that touches the same
100
+ * assembly. The optional budget listener registers with `prepend: true` so a
101
+ * later listener can never override the first-round cap after we set it.
105
102
  */
106
103
  export const inject = []
107
104
 
@@ -113,15 +110,16 @@ const PROMOTE_EVENTS = {
113
110
  }
114
111
 
115
112
  /** Every config key this plugin accepts — anything else is a typo. */
116
- const ALLOWED_KEYS = new Set(['bootstrapTools', 'promoteOn', 'bootstrapMaxTokens', 'suppressedContextSources', 'compactionTools'])
113
+ const ALLOWED_KEYS = new Set(['bootstrapTools', 'promoteOn', 'bootstrapMaxTokens', 'compactionTools', 'includeSubagents'])
117
114
 
118
- /**
119
- * Context sources stripped from the first request by default. Both are
120
- * automatic `agent/pre-step` injections: the available-skills reminder
121
- * (`skill-catalog`) and the AGENTS.md/CLAUDE.md workspace digest
122
- * (`agent-instructions`). True Minimal mounts neither plugin.
123
- */
124
- const DEFAULT_SUPPRESSED_SOURCES = ['skill-catalog', 'agent-instructions']
115
+ /** Validate an optional boolean flag with a default. */
116
+ function booleanOption(value, field, fallback) {
117
+ if (value === undefined) return fallback
118
+ if (typeof value !== 'boolean') {
119
+ throw new TypeError(`${name}: ${field} must be a boolean`)
120
+ }
121
+ return value
122
+ }
125
123
 
126
124
  /**
127
125
  * The default first-request catalog: the OFFICIAL Minimal preset's exact tool
@@ -152,19 +150,6 @@ function parsePromoteOn(value) {
152
150
  throw new TypeError(`${name}: promoteOn must be one of "tool-call", "assistant-message", "either"; got ${JSON.stringify(value)}`)
153
151
  }
154
152
 
155
- /**
156
- * Validate the suppressed context sources. Unlike the bootstrap tool lists,
157
- * an explicitly empty array is meaningful: it disables the context filter
158
- * while keeping the tool bootstrap.
159
- */
160
- function sourceList(value, field, fallback) {
161
- if (value === undefined) return new Set(fallback)
162
- if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
163
- throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
164
- }
165
- return new Set(value)
166
- }
167
-
168
153
  /**
169
154
  * Validate the optional first-request output cap. `undefined` means NO cap:
170
155
  * the Minimal tool schema anchors at the adapter-default maxTokens, and the
@@ -194,13 +179,13 @@ export function apply(ctx, config) {
194
179
  const bootstrapTools = stringList(source.bootstrapTools, 'bootstrapTools')
195
180
  const promoteEvents = parsePromoteOn(source.promoteOn)
196
181
  const bootstrapMaxTokens = optionalPositiveInt(source.bootstrapMaxTokens, 'bootstrapMaxTokens')
197
- const suppressedSources = sourceList(source.suppressedContextSources, 'suppressedContextSources', DEFAULT_SUPPRESSED_SOURCES)
182
+ const includeSubagents = booleanOption(source.includeSubagents, 'includeSubagents', false)
198
183
  // Core work set exposed after a compaction, before re-promotion. Empty
199
184
  // means "no compaction recovery catalog": the session stays on the
200
185
  // bootstrap pair until a new promotion signal.
201
186
  const compactionTools = stringListOrEmpty(source.compactionTools, 'compactionTools')
202
187
 
203
- const promotion = createEpochPromotion(promoteEvents)
188
+ const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
204
189
  ctx.on('session/event', (session, event) => promotion.observe(session, event))
205
190
 
206
191
  let warned = false
@@ -270,7 +255,9 @@ export function apply(ctx, config) {
270
255
  return keepTools(assembled, keep, false)
271
256
  }
272
257
  // Controlled phase: the bootstrap pair; after a compaction, plus the
273
- // compaction work set so mid-task work can continue.
258
+ // compaction work set so mid-task work can continue. Context control is
259
+ // NOT here: the companion `context-gate` plugin owns it (see the header
260
+ // note), so this filter touches only the tool catalog.
274
261
  const { boundary } = status
275
262
  const keep = new Set(bootstrapTools)
276
263
  if (boundary >= 0) for (const toolName of compactionTools) keep.add(toolName)
@@ -311,28 +298,4 @@ export function apply(ctx, config) {
311
298
  }
312
299
  }, { prepend: true })
313
300
  }
314
-
315
- // Strip first-step injected reminders (skill catalog, AGENTS.md) during
316
- // bootstrap. Because this listener is the first registered (see the inject
317
- // note, the row order in agent.cordis.yml, and `prepend` below), the strip
318
- // is the final waterfall transform and actually removes what later
319
- // listeners inject.
320
- ctx.on('agent/pre-step', async ({ agent }, next) => {
321
- // Downstream errors propagate untouched; only this filter's own logic is guarded.
322
- const decision = await next()
323
- if (decision.kind === 'reject') return decision
324
- try {
325
- if (promotion.status(agent).promoted || suppressedSources.size === 0) return decision
326
- if (!Array.isArray(decision.messages)) return decision
327
- const kept = decision.messages.filter((message) => {
328
- const kind = message?.source?.kind
329
- return typeof kind !== 'string' || !suppressedSources.has(kind)
330
- })
331
- return kept.length === decision.messages.length ? decision : { ...decision, messages: kept }
332
- } catch (error) {
333
- // A filter bug must never eat context: degrade to keeping every message.
334
- warnOnce(`${name}: pre-step context filter failed, keeping injected context: ${String((error && error.message) || error)}`)
335
- return decision
336
- }
337
- }, { prepend: true })
338
301
  }
@@ -1,83 +0,0 @@
1
- /**
2
- * turn-anchor — prompt-tool 可选附加件:首轮独立锚定轮。
3
- *
4
- * 启用后,会话首个真实用户消息不直接发给模型:先把任务原样挪进
5
- * `agent.inbox` 的 `next-step`(持久事件),首步只发一条 anchorText 作为
6
- * 独立输入。模型先回应锚定句(实测首词稳定 "We need…"),driver 随后在同
7
- * 一轮内自动消费 next-step 中的真实任务继续执行——因此用户只发一次消息,
8
- * 但模型看到的首轮是锚定句。
9
- *
10
- * 状态从持久 session events 推导(resume/reload 安全):
11
- * - 已处理/已拆轮:events 含 source.plugin=turn-anchor 的消息,或会话已
12
- * 晋升(tool/call | assistant/message);
13
- * - 任务在 next-step inbox 中持久化,进程重启后由 driver 继续消费。
14
- * 失败兜底:inbox.append 抛错时原样返回决策,绝不吞掉用户任务。
15
- */
16
-
17
- /** Cordis plugin name used by loader diagnostics. */
18
- export const name = 'turn-anchor'
19
-
20
- /** No inject list: nothing to resolve, only event listeners. */
21
- export const inject = []
22
-
23
- /** 消息 id:优先 crypto.randomUUID,旧运行时回退到随机串。 */
24
- function newMessageId() {
25
- return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
26
- ? crypto.randomUUID()
27
- : `turn-anchor-${Date.now()}-${Math.random().toString(36).slice(2)}`
28
- }
29
-
30
- export function apply(ctx, config) {
31
- const anchorText = typeof config.anchorText === 'string' && config.anchorText.length > 0
32
- ? config.anchorText
33
- : undefined
34
- if (anchorText === undefined) return
35
-
36
- /** Sessions already handled in this process (memo; truth stays in events/inbox). */
37
- const handled = new Set()
38
-
39
- /** 是否已经拆过轮:锚定句消息是持久 user/message,兼容 data.message 嵌套形状。 */
40
- const seenAnchor = (session) => session.events.some((event) => {
41
- const payload = event.data && typeof event.data.message === 'object' && event.data.message !== null
42
- ? event.data.message
43
- : event.data
44
- return payload?.source?.plugin === 'turn-anchor'
45
- })
46
-
47
- ctx.on('agent/pre-step', async ({ agent }, next) => {
48
- const decision = await next()
49
- if (decision.kind === 'reject') return decision
50
- if (agent === undefined) return decision
51
- const session = agent.session
52
- if (session === undefined || handled.has(session.id)) return decision
53
-
54
- const events = session.events
55
- const promoted = events.some((event) => event.type === 'tool/call' || event.type === 'assistant/message')
56
- if (promoted || seenAnchor(session)) {
57
- handled.add(session.id)
58
- return decision
59
- }
60
-
61
- // 首步里的真实用户消息(source.kind === 'user')才是任务;插件消息原样保留。
62
- const tasks = decision.messages.filter((message) => message?.source?.kind === 'user')
63
- if (tasks.length === 0) return decision
64
-
65
- // 任务挪进 next-step:driver 在本轮锚定步结束后自动 claim 并继续执行。
66
- try {
67
- for (const task of tasks) agent.inbox.append('next-step', task)
68
- } catch {
69
- // 入队失败(id 冲突/坏状态)→ 不拆轮,原样发送,绝不丢任务。
70
- return decision
71
- }
72
-
73
- handled.add(session.id)
74
- const anchor = {
75
- id: newMessageId(),
76
- role: 'user',
77
- content: [{ type: 'text', text: anchorText }],
78
- source: { kind: 'plugin', plugin: 'turn-anchor', form: 'notice', summary: 'turn-anchor 首轮独立锚定句' },
79
- }
80
- const kept = decision.messages.filter((message) => message?.source?.kind !== 'user')
81
- return { ...decision, messages: [anchor, ...kept] }
82
- })
83
- }