dsh-plugin-prompt-tool 3.0.1 → 3.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.
Files changed (57) hide show
  1. package/README.md +17 -1
  2. package/engine/actions/catalog.mjs +2 -1
  3. package/engine/actions/index.mjs +5 -3
  4. package/engine/actions/shared.mjs +26 -12
  5. package/engine/actions/tool-visibility.mjs +31 -14
  6. package/engine/compositions/source/local/context-provenance.yml +9 -0
  7. package/engine/compositions/source/local/generation-recovery.yml +10 -0
  8. package/engine/compositions/source/local/retrieval.yml +7 -0
  9. package/engine/compositions/source/local/soul-ledger.yml +3 -0
  10. package/engine/compositions/source/local/soul-maintenance.yml +9 -0
  11. package/engine/conditions/examples.mjs +1 -0
  12. package/engine/conditions/index.mjs +7 -0
  13. package/engine/conditions/manifest.mjs +28 -0
  14. package/engine/context-idle-compaction.mjs +58 -0
  15. package/engine/context-provenance.d.mts +7 -0
  16. package/engine/context-provenance.mjs +156 -0
  17. package/engine/context-pruner.mjs +64 -0
  18. package/engine/context-routing.mjs +45 -0
  19. package/engine/context-summary.mjs +80 -0
  20. package/engine/fillers.mjs +37 -4
  21. package/engine/generation-recovery-coordinator.mjs +40 -0
  22. package/engine/generation-recovery-maintenance.mjs +18 -0
  23. package/engine/generation-recovery.mjs +153 -0
  24. package/engine/host-package.mjs +13 -0
  25. package/engine/layers.d.mts +4 -0
  26. package/engine/layers.mjs +57 -20
  27. package/engine/present-as.mjs +2 -2
  28. package/engine/retrieval-reference.d.mts +5 -0
  29. package/engine/retrieval-reference.mjs +26 -0
  30. package/engine/retrieval.mjs +17 -0
  31. package/engine/rule-engine.mjs +1 -0
  32. package/engine/rule-runtime.d.mts +8 -1
  33. package/engine/rule-runtime.mjs +18 -10
  34. package/engine/rule-spec.mjs +4 -2
  35. package/engine/schema.mjs +1 -1
  36. package/engine/service-names.d.mts +5 -0
  37. package/engine/service-names.mjs +5 -0
  38. package/engine/soul-ledger.mjs +8 -0
  39. package/engine/soul-maintenance.mjs +20 -0
  40. package/lib/client.js +16 -6
  41. package/lib/client.js.map +1 -1
  42. package/lib/index.mjs +1953 -246
  43. package/modules/soul/assets/ledger.md +4 -2
  44. package/modules/soul/assets/manifest.yml +2 -0
  45. package/modules/soul/assets/reference/00-layers.md +27 -27
  46. package/modules/soul/assets/reference/02-echo.md +161 -161
  47. package/modules/soul/assets/reference/05-doctor.md +67 -67
  48. package/modules/soul/assets/reference/10-echo-anatomy.md +109 -109
  49. package/modules/soul/assets/reference/ECHO-/346/235/203/345/250/201/345/217/243/345/276/204.md +202 -202
  50. package/modules/soul/assets/reference/ECHO/345/205/255/345/261/202/344/270/216mnemon/345/215/217/345/220/214/350/257/264/346/230/216/344/271/246.md +229 -229
  51. package/modules/soul/assets/templates/agents-template.md +3 -0
  52. package/modules/soul/assets/templates/self-template.md +19 -0
  53. package/modules/soul/module.yml +71 -13
  54. package/modules/tool-surface/module.yml +8 -5
  55. package/package.json +1 -1
  56. package/skills/dsh-module/reference.md +2 -2
  57. package/templates/80-tool-surface.yml +8 -7
@@ -0,0 +1,64 @@
1
+ import { makeSessionReference } from './retrieval-reference.mjs'
2
+
3
+ const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
4
+
5
+ /** 官方裁剪算法不变,只在本次生成marker的Unicode位置附来源。 */
6
+ function referencedContent(content, headChars, reference) {
7
+ const result = [...content]
8
+ let consumed = 0
9
+ for (let index = 0; index < result.length; index++) {
10
+ const block = result[index]
11
+ if (block.type !== 'text') continue
12
+ const points = [...block.text]
13
+ if (consumed + points.length <= headChars) { consumed += points.length; continue }
14
+ const offset = headChars - consumed
15
+ if (points.slice(offset, offset + [...PRUNE_MARKER].length).join('') !== PRUNE_MARKER) return null
16
+ result[index] = { ...block, text: points.slice(0, offset).join('') + PRUNE_MARKER + `[source: ${JSON.stringify(reference)}]\n` + points.slice(offset + [...PRUNE_MARKER].length).join('') }
17
+ return result
18
+ }
19
+ return null
20
+ }
21
+
22
+ export function createProvenancePruner(PrunerClass, { sourceModuleId, isActive = () => true, signalOf = () => undefined, makeReference = makeSessionReference } = {}) {
23
+ return class ProvenancePruner extends PrunerClass {
24
+ pruneSession(session, signal = signalOf()) {
25
+ const check = () => {
26
+ signal?.throwIfAborted()
27
+ if (!isActive()) throw new Error('context-provenance: pruner released')
28
+ }
29
+ check()
30
+ const events = session.snapshotEvents()
31
+ const bySeq = new Map(events.map(event => [event.seq, event]))
32
+ const candidates = [...new Set(session.surface.nodes)].map(seq => bySeq.get(seq)).filter(event => event?.type === 'tool/result')
33
+ const pruned = []
34
+ let charsRemoved = 0
35
+ for (const event of candidates) {
36
+ check()
37
+ const seq = event.seq, original = session.deriveEventMessage(event)
38
+ let content = super.pruneContent(original.content)
39
+ if (content === null) continue
40
+ const charsBefore = this.measureContent(original.content)
41
+ try {
42
+ const reference = makeReference(sourceModuleId, session.id, events, [seq])
43
+ const cited = referencedContent(content, this.config.headChars, reference)
44
+ if (!cited || this.measureContent(cited) > this.config.thresholdChars || this.measureContent(cited) >= charsBefore) throw new Error('reference exceeds pruning budget')
45
+ content = cited
46
+ } catch (error) {
47
+ this.ctx.logger?.warn?.(`context-provenance: pruned without reference (${String(error?.message ?? error)})`)
48
+ }
49
+ const charsAfter = this.measureContent(content)
50
+ if (charsAfter > this.config.thresholdChars || charsAfter >= charsBefore) throw new Error('context-provenance: invalid prune result')
51
+ const shadowedTokenCount = this.ctx.tokenMeter.estimateMessage(original)
52
+ check()
53
+ session.append('compaction/prune', { shadowedRange: { start: seq, end: seq }, shadowedSeqs: [seq], shadowedTokenCount })
54
+ check()
55
+ const replacement = session.append('tool/result', { ...event.data, message: { ...original, content } }, {
56
+ surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq],
57
+ })
58
+ pruned.push({ originalSeq: seq, replacementSeq: replacement.seq, callId: original.source.callId, charsBefore, charsAfter })
59
+ charsRemoved += charsBefore - charsAfter
60
+ }
61
+ return { pruned, charsRemoved }
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,45 @@
1
+ import { Context } from '@deepseek-ai/cordis'
2
+
3
+ /** 只路由声明Agent,原provider的方法和其它监听器保持原所有权。 */
4
+ export function routeContextRuntime(ctx, agent, runtime) {
5
+ let active = true
6
+ const disposers = []
7
+ for (const service of ['compaction', 'toolResultPruner']) {
8
+ if (!runtime[service]) continue
9
+ const dependency = ctx.inject([service], dependent => {
10
+ // 已发布Fiber.store公开了这次依赖快照的精确provider身份。
11
+ const owner = dependent.fiber.store[service].fiber
12
+ const carriers = new WeakMap()
13
+ const offDispatch = service === 'compaction' ? dependent.on('internal/dispatch', (_mode, event, args, carrier) => {
14
+ if (!active || !['agent/pre-step', 'agent/request-error', 'agent/request'].includes(event)
15
+ || args[0]?.agent !== agent || !carrier) return
16
+ if (carriers.has(carrier)) return
17
+ const original = carrier[Context.filter]
18
+ const filter = function (candidate) {
19
+ return (!original || original.call(this, candidate)) && (!active || candidate.fiber !== owner)
20
+ }
21
+ carriers.set(carrier, original)
22
+ carrier[Context.filter] = filter
23
+ }) : () => {}
24
+ const offGet = dependent.on('internal/get', (_consumer, key, _error, next) => {
25
+ const original = next()
26
+ if (!active || key !== service || !original) return original
27
+ return new Proxy(original, {
28
+ get(target, property) {
29
+ const method = target[property]
30
+ if (typeof method !== 'function') return method
31
+ return (...args) => {
32
+ const subject = service === 'toolResultPruner' ? args[0] : property === 'compactRegion' ? args[2] : args[0]
33
+ const selected = service === 'toolResultPruner' ? subject === agent.session : subject === agent
34
+ if (active && selected && typeof runtime[service][property] === 'function') return runtime[service][property](...args)
35
+ return method.apply(target, args)
36
+ }
37
+ },
38
+ })
39
+ }, { prepend: true })
40
+ return () => { offDispatch(); offGet() }
41
+ })
42
+ disposers.push(() => dependency.dispose())
43
+ }
44
+ return () => { active = false; for (const dispose of disposers) void dispose() }
45
+ }
@@ -0,0 +1,80 @@
1
+ import { makeSessionReference } from './retrieval-reference.mjs'
2
+
3
+ // shortcut: Basic alpha.2 未公开导出frameSummary,升级官方framing时同步此版本化快照。
4
+ const CHECKPOINT_OPEN = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n<compacted-summary>'
5
+ export function frameSummary(summary) {
6
+ return [{ type: 'text', text: CHECKPOINT_OPEN }, ...summary, { type: 'text', text: '</compacted-summary>' }]
7
+ }
8
+ const framed = summary => ({
9
+ id: 'context-provenance-budget', role: 'user', source: { kind: 'compact-checkpoint', compactionId: 'context-provenance-budget' },
10
+ content: frameSummary(summary),
11
+ })
12
+
13
+ function sourceSnapshot(input, session, meter) {
14
+ const events = session.snapshotEvents(), bySeq = new Map(events.map(event => [event.seq, event]))
15
+ const nodes = session.surface.nodes.map(seq => {
16
+ const event = bySeq.get(seq)
17
+ if (!event) throw new Error('source event missing')
18
+ return { seq, message: session.deriveEventMessage(event) }
19
+ })
20
+ let messages = input.messages
21
+ const head = nodes[0]?.message
22
+ // Basic 把system头作为缓存前缀再加一次;真正选中system时后面仍保留它。
23
+ if (head?.role === 'system' && messages[0]?.id === head.id) messages = messages.slice(1)
24
+ if (!messages.length || messages.some(message => typeof message.id !== 'string' || !message.id)) throw new Error('source message identity unavailable')
25
+ const matches = []
26
+ for (let start = 0; start < nodes.length; start++) {
27
+ if (nodes[start].message?.id !== messages[0].id) continue
28
+ let position = 0
29
+ for (let end = start; end < nodes.length; end++) {
30
+ const message = nodes[end].message
31
+ if (message !== null) {
32
+ if (message?.id !== messages[position]?.id) break
33
+ position++
34
+ }
35
+ if (position !== messages.length) continue
36
+ // 空投影可能属于区间也可能不属于;没有真实事务端点时不猜。
37
+ if (nodes[start - 1]?.message === null || nodes[end + 1]?.message === null) throw new Error('source span ambiguous')
38
+ matches.push(nodes.slice(start, end + 1).map(node => node.seq))
39
+ break
40
+ }
41
+ }
42
+ if (matches.length !== 1) throw new Error('source span unavailable or ambiguous')
43
+ const seqs = matches[0], measured = meter.measure(session).nodes
44
+ if (measured.length !== nodes.length || measured.some((node, index) => node.seq !== nodes[index].seq)) throw new Error('source token surface changed')
45
+ const selected = new Set(seqs)
46
+ const tokens = measured.filter(node => selected.has(node.seq)).reduce((total, node) => total + node.tokens, 0)
47
+ if (!Number.isFinite(tokens) || tokens <= 0) throw new Error('source token budget unavailable')
48
+ return { events, seqs, tokens }
49
+ }
50
+
51
+ export function createProvenanceCompaction(BasicClass, { sourceModuleId, isActive = () => true, signalOf = () => undefined, makeReference = makeSessionReference } = {}) {
52
+ return class ProvenanceCompaction extends BasicClass {
53
+ async summarize(input, agent, signal = signalOf(agent)) {
54
+ const check = () => {
55
+ signal?.throwIfAborted()
56
+ if (!isActive(agent)) throw new DOMException('context-provenance: compaction released', 'AbortError')
57
+ }
58
+ const warn = message => { try { this.ctx.logger?.warn?.('context-provenance: summary without reference (' + message + ')') } catch { /* 诊断不能使原本可用的压缩失败。 */ } }
59
+ check()
60
+ let snapshot
61
+ try { snapshot = sourceSnapshot(input, agent.session, this.ctx.tokenMeter) } catch (error) { warn(String(error?.message ?? error)) }
62
+ const result = await super.summarize(input, agent, signal)
63
+ check()
64
+ if (!snapshot) return result
65
+ try {
66
+ const reference = makeReference(sourceModuleId, agent.session.id, snapshot.events, snapshot.seqs)
67
+ const summary = [{ type: 'text', text: `[source: ${JSON.stringify(reference)}]\n` }, ...result.summary]
68
+ const tokens = this.ctx.tokenMeter.estimateMessage(framed(summary))
69
+ if (!Number.isFinite(tokens) || tokens >= snapshot.tokens) {
70
+ warn('reference exceeds shrink budget')
71
+ return result
72
+ }
73
+ return { ...result, summary }
74
+ } catch (error) {
75
+ warn(String(error?.message ?? error))
76
+ return result
77
+ }
78
+ }
79
+ }
80
+ }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * fillers — 引擎内置的动态填充器(strategy=placeholder 的数据源)。
3
- * 全部遵循同一铁律:服务缺失或列表失败时返回 null(跳过该配置),绝不伤及会话。
3
+ * 按本次求值读取宿主能力;失败由填充器或调用方诊断并跳过该配置。
4
4
  */
5
5
 
6
6
  import { getService, parseToolNames } from './shared.mjs'
7
+ import { toolScopeSnapshot } from './tool-scope.mjs'
7
8
  import { machinePaths } from './machine-paths.mjs'
8
9
  import { SAFE_ENV_KEYS } from './env-safety.mjs'
9
10
  import { createInstructionHintResolver } from './instruction-hint.mjs'
10
- import { DISCOVERY_SERVICE } from './service-names.mjs'
11
+ import { DISCOVERY_SERVICE, SOUL_SELF_SERVICE } from './service-names.mjs'
11
12
 
12
13
  const name = 'prompt-config-engine'
13
14
 
@@ -16,7 +17,22 @@ function explicitTemplate(config) {
16
17
  return texts.length > 0 ? texts.join('\n\n') : typeof config.params?.text === 'string' ? config.params.text : ''
17
18
  }
18
19
 
19
- /** 只读同规则工具面声明;文案、条件和投递仍归普通注入动作。 */
20
+ /**
21
+ * 只读同规则工具面声明;文案、条件和投递仍归普通注入动作。
22
+ * RESIDENT_TOOLS 只列**当前可见**的常驻工具:部署层名单可以登记替换提供方(如 dsh-ops 的
23
+ * ops_bash),但该插件未必安装;向模型宣告一个不存在的常驻工具会把「解锁」变成错误前提。
24
+ * 观测到工具面但一个常驻名都不在时(requireMatch 全缺/放宽场景)跳过提示;
25
+ * 观测不到工具面(服务缺失或读取失败)才回落声明名单——「没看到」不等于「都没有」。
26
+ */
27
+ function visibleToolNames(ctx, agent) {
28
+ try {
29
+ const tools = getService(ctx, 'tools')
30
+ if (tools === undefined || typeof tools.schemas !== 'function') return undefined
31
+ return new Set(toolScopeSnapshot(tools, agent).visible.map((schema) => schema?.name))
32
+ } catch {
33
+ return undefined
34
+ }
35
+ }
20
36
  function createToolCatalogResolver(config) {
21
37
  if (config.params?.templateEvaluation === true) throw new TypeError('tool-catalog is read-only and does not support templateEvaluation')
22
38
  if (typeof config.params?.action !== 'string' || config.params.action.trim().length === 0) {
@@ -28,12 +44,20 @@ function createToolCatalogResolver(config) {
28
44
  const source = config.toolCatalogSource
29
45
  if (source === undefined) throw new TypeError('tool-catalog requires a same-rule tool-visibility action')
30
46
  const deny = new Set(source.deny ?? [])
31
- const resident = [...new Set(source.allow)].filter(tool => !deny.has(tool))
47
+ const declared = [...new Set(source.allow)].filter(tool => !deny.has(tool))
32
48
  const adapter = source.discovery === undefined ? undefined
33
49
  : getService(ctx, DISCOVERY_SERVICE)?.get?.(agent, source.discovery.kind)
34
50
  if (source.discovery !== undefined && typeof adapter?.tool !== 'string') {
35
51
  throw new Error(`tool-catalog discovery adapter ${source.discovery.kind} is unavailable`)
36
52
  }
53
+ let resident = declared
54
+ const visible = visibleToolNames(ctx, agent)
55
+ if (visible !== undefined) {
56
+ const present = declared.filter((name) => visible.has(name))
57
+ // 观测到全缺时不宣告任何常驻工具:与 requireMatch 放宽同源,避免「解锁」建在错误前提上。
58
+ if (present.length === 0) return null
59
+ resident = present
60
+ }
37
61
  return { text, variables: { RESIDENT_TOOLS: resident.join(', '), DISCOVERY_TOOL: adapter?.tool ?? '' } }
38
62
  }
39
63
  }
@@ -163,12 +187,21 @@ function createSkillCatalogResolver(config) {
163
187
  }
164
188
  }
165
189
 
190
+ function createSoulSelfResolver(config) {
191
+ return ({ ctx, agent, signal }) => {
192
+ const service = getService(ctx, SOUL_SELF_SERVICE)
193
+ if (typeof service?.render !== 'function') throw new Error('SELF dependency missing')
194
+ return { text: service.render(config.sourceModuleId, agent, signal) }
195
+ }
196
+ }
197
+
166
198
  /** 动态填充器:strategy=placeholder 时按 fill 键在注入点填充内容。 */
167
199
  export const FILLERS = {
168
200
  'instruction-hint': (config) => createInstructionHintResolver(config),
169
201
  'env-facts': (config) => createEnvFactsResolver(config),
170
202
  'skill-catalog': (config) => createSkillCatalogResolver(config),
171
203
  'tool-catalog': (config) => createToolCatalogResolver(config),
204
+ 'soul-self': (config) => createSoulSelfResolver(config),
172
205
  }
173
206
 
174
207
  export function createPlaceholderResolver(config) {
@@ -0,0 +1,40 @@
1
+ import { historyEvents } from './history.mjs'
2
+
3
+ const visible = content => Array.isArray(content) && content.some(block => block.type !== 'reasoning' && (block.type !== 'text' || block.text?.trim()))
4
+
5
+ /** 候选只在 durable turn/end 后发布;流与结算共用官方 turn/step。 */
6
+ export function recoveryCoordinator(ctx, agent, config, options, isActive) {
7
+ let frame
8
+ let emitted
9
+ const offRequest = ctx.on('agent/request', (payload, next) => {
10
+ if (payload.agent !== agent) return next()
11
+ frame = { turn: payload.turn, step: payload.step, signal: payload.signal, visible: false }
12
+ return next()
13
+ })
14
+ const offEvent = ctx.on('session/event', (session, event) => {
15
+ if (!isActive() || session.id !== agent.session.id || !frame) return
16
+ const data = event.data ?? {}
17
+ if (event.type === 'assistant/message' && data.turn === frame.turn) {
18
+ if (data.step !== frame.step) return
19
+ frame.visible = visible(data.message?.content)
20
+ if (data.interrupted) frame.interrupted = true
21
+ }
22
+ if (event.type !== 'turn/end' || data.turn !== frame.turn || frame.signal?.aborted || frame.interrupted) return
23
+ if (data.reason?.kind !== 'completed' && data.reason?.kind !== 'no-visible-output') return
24
+ const execution = `${frame.turn}:${frame.step}`
25
+ if (execution === emitted || (!frame.reason && (!config.recoverEmpty || frame.visible))) return
26
+ emitted = execution
27
+ const candidate = { sessionId: session.id, execution, turn: frame.turn, step: frame.step, reason: frame.reason ?? 'empty-final-step', signal: frame.signal }
28
+ options.onRecoveryCandidate?.(candidate)
29
+ options.recover?.(candidate)
30
+ })
31
+ return {
32
+ mark(reason) { if (frame && !frame.signal?.aborted) frame.reason = reason },
33
+ dispose() { offRequest(); offEvent() },
34
+ // 迟到idle回调不能跨过新的用户执行边界。
35
+ unchanged(candidate) {
36
+ const latest = historyEvents(agent.session).findLast(event => event.type === 'turn/start')
37
+ return !latest || latest.data?.turn === candidate.turn
38
+ },
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ /** 维护窗口与范围选择归上下文runtime,这里只决定是否允许扩大范围。 */
2
+ export async function recoveryMaintenance(runtime, signal, allowFullFallback, valid, report) {
3
+ if (!runtime) { report('context runtime unavailable; recovery stopped'); return null }
4
+ let result
5
+ try {
6
+ result = await runtime.compactRecovery(signal)
7
+ } catch (error) {
8
+ if (!valid() || error?.name === 'AbortError' || error?.code === 'ABORTED') return null
9
+ if (!allowFullFallback) { report(`regional maintenance failed; recovery stopped (${String(error?.message ?? error)})`); return null }
10
+ result = await runtime.compactNow(signal)
11
+ }
12
+ if (!valid()) return null
13
+ if (result === null || !Array.isArray(result?.shadowedSeqs) || result.shadowedSeqs.length === 0) {
14
+ report('maintenance no-op; recovery stopped')
15
+ return null
16
+ }
17
+ return result
18
+ }
@@ -0,0 +1,153 @@
1
+ import { bool, defineConfig, enumOf, int, passthrough } from './fields.mjs'
2
+ import { capabilityAudience } from './agent-capability.mjs'
3
+ import { recoveryCoordinator } from './generation-recovery-coordinator.mjs'
4
+ import { pluginMessage } from './layers.mjs'
5
+ import { recoveryMaintenance } from './generation-recovery-maintenance.mjs'
6
+
7
+ export const name = 'generation-recovery'
8
+ export const inject = ['llm']
9
+
10
+ const recoveryLimit = passthrough((value, plugin, field) => {
11
+ if (value === undefined) return 0
12
+ if (!Number.isSafeInteger(value) || value < 0 || value > 1000) throw new TypeError(`${plugin}: ${field} must be an integer from 0 to 1000`)
13
+ return value
14
+ })
15
+
16
+ export const configContract = defineConfig({
17
+ enabled: bool({ default: false }),
18
+ audience: enumOf(['all', 'main', 'subagent'], { default: 'all' }),
19
+ textRepeatChars: int({ min: 3, default: 4096 }),
20
+ reasoningRepeatChars: int({ min: 3, default: 8192 }),
21
+ silentReasoningChars: int({ min: 0, default: 0 }),
22
+ recoverEmpty: bool({ default: false }),
23
+ mainRecoveryLimit: recoveryLimit,
24
+ subagentRecoveryLimit: recoveryLimit,
25
+ maintenanceCompression: bool({ default: false }),
26
+ allowFullFallback: bool({ default: false }),
27
+ maxCycleChars: passthrough((value, plugin, field) => {
28
+ if (value === undefined) return 256
29
+ if (!Number.isSafeInteger(value) || value < 1 || value > 1024) throw new TypeError(`${plugin}: ${field} must be an integer from 1 to 1024`)
30
+ return value
31
+ }),
32
+ })
33
+
34
+ // 逐 Unicode 字符比较周期,内存只随最长周期增长,不保留整段推理。
35
+ function repeatDetector(threshold, maxCycle) {
36
+ const tail = new Array(maxCycle)
37
+ const matches = new Float64Array(maxCycle + 1)
38
+ let seen = 0
39
+ return text => {
40
+ let offset = 0
41
+ for (const char of text) {
42
+ offset += char.length
43
+ let repeated = false
44
+ for (let period = 1; period <= Math.min(seen, maxCycle); period++) {
45
+ const needed = Math.max(threshold - period, 2 * period)
46
+ matches[period] = tail[(seen - period) % maxCycle] === char ? Math.min(matches[period] + 1, needed) : 0
47
+ if (matches[period] >= needed) repeated = true
48
+ }
49
+ tail[seen % maxCycle] = char
50
+ seen++
51
+ if (repeated) return offset
52
+ }
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ export function apply(ctx, raw, options = {}) {
58
+ const { agent } = options
59
+ const config = configContract.parse(raw, name)
60
+ if (!config.enabled || typeof agent?.session?.id !== 'string') return
61
+ if (config.audience !== 'all' && config.audience !== capabilityAudience(agent)) return
62
+ let active = true
63
+ const report = message => ctx.logger?.warn?.(`${name}: ${message}`)
64
+ const limit = capabilityAudience(agent) === 'subagent' ? config.subagentRecoveryLimit : config.mainRecoveryLimit
65
+ const coordinator = recoveryCoordinator(ctx, agent, config, { ...options, recover(candidate) {
66
+ if (!limit) return
67
+ if (!options.recoveryState) { report('persistent recovery state unavailable; automatic recovery disabled'); return }
68
+ try {
69
+ const claimed = options.recoveryState.claim(agent.session.id, candidate.execution, limit)
70
+ if (!claimed.claimed) { report(`recovery unavailable (${claimed.used}/${limit})`); return }
71
+ void (async () => {
72
+ try {
73
+ await agent.whenIdle()
74
+ const valid = () => active && !candidate.signal?.aborted && coordinator.unchanged(candidate)
75
+ if (!valid()) return
76
+ let maintenance
77
+ if (config.maintenanceCompression) {
78
+ maintenance = await recoveryMaintenance(options.contextRuntimeOf?.(agent), candidate.signal, config.allowFullFallback, valid, report)
79
+ if (!maintenance) return
80
+ }
81
+ if (!valid()) return
82
+ const text = '上一轮输出因异常重复或仅有推理而未完成。请停止重复,检查已完成的工作,并给出有效结果或明确说明阻塞原因。'
83
+ + (maintenance ? `\n维护压缩已完成,实际来源事件:${maintenance.shadowedSeqs.join(',')}。` : '')
84
+ agent.followup(pluginMessage('generation-recovery', text, '恢复异常生成'))
85
+ } catch (error) { report(String(error?.message ?? error)) }
86
+ })()
87
+ } catch (error) { report(String(error?.message ?? error)) }
88
+ } }, () => active)
89
+ const stop = ctx.effect(() => () => { active = false })
90
+ const off = ctx.on('llm/stream', (options, next) => {
91
+ if (!active || options.signal?.aborted || options.purpose !== undefined || options.sessionId !== agent.session.id) return next()
92
+ return (async function* () {
93
+ const detectors = {
94
+ text: repeatDetector(config.textRepeatChars, config.maxCycleChars),
95
+ reasoning: repeatDetector(config.reasoningRepeatChars, config.maxCycleChars),
96
+ }
97
+ const open = new Map()
98
+ const closed = new Set()
99
+ const finalized = new Set()
100
+ let hasToolCall = false
101
+ let visibleText = false
102
+ let reasoningChars = 0
103
+ for await (const chunk of next()) {
104
+ if (finalized.has(chunk.index)) continue
105
+ if (!active || options.signal?.aborted) { yield chunk; continue }
106
+ if (chunk.type === 'tool-call-delta' || chunk.blockType === 'tool-call' || chunk.block?.type === 'tool-call') hasToolCall = true
107
+ if (chunk.type === 'block-start') open.set(chunk.index, { type: chunk.blockType, text: '' })
108
+ if (chunk.type === 'block-end') { open.delete(chunk.index); closed.add(chunk.index) }
109
+ const kind = chunk.type === 'text-delta' ? 'text' : chunk.type === 'reasoning-delta' ? 'reasoning' : undefined
110
+ if (kind === undefined || closed.has(chunk.index)) { yield chunk; continue }
111
+ if (!open.has(chunk.index)) open.set(chunk.index, { type: kind, text: '' })
112
+ // 已闭合工具也要保留原始finish语义,不把工具回合伪装成普通stop。
113
+ const safe = !hasToolCall && [...open.values()].every(block => block.type === 'text' || block.type === 'reasoning')
114
+ if (kind === 'text' && chunk.text.trim()) visibleText = true
115
+ let cutoff = safe ? detectors[kind](chunk.text) : undefined
116
+ let cause = 'repeated-' + kind
117
+ if (safe && kind === 'reasoning' && !visibleText && config.silentReasoningChars > 0) {
118
+ const chars = [...chunk.text]
119
+ const remaining = config.silentReasoningChars - reasoningChars
120
+ reasoningChars += chars.length
121
+ if (remaining > 0 && remaining <= chars.length) {
122
+ const silentCutoff = chars.slice(0, remaining).join('').length
123
+ if (cutoff === undefined || silentCutoff < cutoff) { cutoff = silentCutoff; cause = 'silent-reasoning' }
124
+ }
125
+ }
126
+ const text = cutoff === undefined ? chunk.text : chunk.text.slice(0, cutoff)
127
+ const block = open.get(chunk.index)
128
+ if (block !== undefined) block.text += text
129
+ yield cutoff === undefined ? chunk : { ...chunk, text }
130
+ if (cutoff === undefined) continue
131
+ if (!active || options.signal?.aborted) {
132
+ const remainder = chunk.text.slice(cutoff)
133
+ if (remainder) yield { ...chunk, text: remainder }
134
+ continue
135
+ }
136
+ for (const [index, value] of open) {
137
+ if (!active || options.signal?.aborted) break
138
+ finalized.add(index)
139
+ yield { type: 'block-end', index, block: value }
140
+ }
141
+ if (!active || options.signal?.aborted) {
142
+ const remainder = chunk.text.slice(cutoff)
143
+ if (!finalized.has(chunk.index) && remainder) yield { ...chunk, text: remainder }
144
+ continue
145
+ }
146
+ coordinator.mark(cause)
147
+ yield { type: 'finish', reason: { kind: 'stop' } }
148
+ return
149
+ }
150
+ })()
151
+ })
152
+ return () => { stop(); off(); coordinator.dispose() }
153
+ }
@@ -49,3 +49,16 @@ export function resolveHostPackage(id, entry, self) {
49
49
  export async function importHostPackage(id, entry, self) {
50
50
  return import(pathToFileURL(resolveHostPackage(id, entry, self)).href)
51
51
  }
52
+
53
+ /** 允许从宿主解析的官方能力包;版本随宿主(alpha/rc 均可),不在插件内做精确匹配。 */
54
+ export const HOST_CAPABILITY_PACKAGES = Object.freeze([
55
+ '@deepseek-ai/dsh-compaction-basic',
56
+ '@deepseek-ai/dsh-compaction-tool-result-pruner',
57
+ '@deepseek-ai/dsh-compaction',
58
+ '@deepseek-ai/dsh-llm',
59
+ ])
60
+
61
+ export async function importHostCapability(id) {
62
+ if (!HOST_CAPABILITY_PACKAGES.includes(id)) throw new Error(`host capability not declared: ${id}`)
63
+ return importHostPackage(id)
64
+ }
@@ -0,0 +1,4 @@
1
+ import type { UserMessage } from '@deepseek-ai/dsh-llm'
2
+
3
+ /** 已有提示词引擎通知消息;使用宿主当前的插件来源契约。 */
4
+ export function pluginMessage(prefix: string, text: string, summary: string): UserMessage