dsh-agentic-tool-catalog 0.0.0-stage → 0.3.2

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/lib/index.js ADDED
@@ -0,0 +1,795 @@
1
+ /**
2
+ * dsh-agentic-tool-catalog —— 由 agent 维护的按需工具目录。
3
+ *
4
+ * 核心:目录变化时由 DSH 原生子代理生成一次分组(agent-curated),插件严格校验后
5
+ * 按目录指纹缓存到 **官方 Storage**(ctx.storageDomain),失败自动重试/沿用最近一次成功分组,
6
+ * 生成完成前用扁平目录过渡;模型通过 `search_tools` 按需加载,`catalog_status` 只读观测。
7
+ *
8
+ * 机制要点(2026-11-10/11 对照 0.2.2-rc.1 源码核对):
9
+ * - 目录来自 `ctx.tools.definitions(scope)`(公开 API);
10
+ * - 用 `agent.ctx.tools.restrict({ deny })` 把未加载工具连 schema 从请求里移除;
11
+ * - restriction 变更会同步触发 `tools/change`,必须带重入守卫;
12
+ * - restriction 释放由插件实例 effect 持有(全局登记表跨实例清理)。
13
+ *
14
+ * @module @local/dsh-agentic-tool-catalog
15
+ */
16
+ import { createHash } from 'node:crypto'
17
+
18
+ import z from '@deepseek-ai/schemastery'
19
+ import { defineTool } from '@deepseek-ai/dsh-tools'
20
+ import { scopeOf } from '@deepseek-ai/dsh-scope'
21
+
22
+ import { buildBootstrapCatalog, catalogSummary, noteText, rankTools, residentMatcher } from './catalog.js'
23
+ import { adaptAgentGroups, catalogFromAgentGroups, createAgentGrouper, validateAgentGroups } from './generation.js'
24
+ import { createCatalogStore } from './store.js'
25
+
26
+ /** Cordis 插件名(Loader 行 id 用它)。 */
27
+ export const name = 'dsh-agentic-tool-catalog'
28
+
29
+ /** 需要工具注册表。 */
30
+ export const inject = ['tools']
31
+
32
+ /** 运行期配置。 */
33
+ export const Config = z.object({
34
+ /** 一次普通 search 最多加载多少个工具。 */
35
+ maxResults: z.number().default(6),
36
+ /** 整组加载上限(query 精确等于组名时)。 */
37
+ groupMaxResults: z.number().default(30),
38
+ /** 是否在系统提示里加动态发现提示。 */
39
+ promptNote: z.boolean().default(true),
40
+ /** 覆盖内置常驻清单(空数组 = 用内置默认)。 */
41
+ resident: z.array(z.string()).default([]),
42
+ /** 往常驻清单追加 glob。 */
43
+ extraKeepResident: z.array(z.string()).default([]),
44
+ /** 强制按需(glob),优先于常驻。 */
45
+ extraDeferred: z.array(z.string()).default([]),
46
+ /** 生成子代理 provider(DSH subagent 服务里的注册名)。 */
47
+ catalogProvider: z.string().default('spawn'),
48
+ /** 单次生成超时(毫秒)。 */
49
+ catalogTimeoutMs: z.number().default(120000),
50
+ /** 缓存命名空间:改这个值会让所有目录指纹失效、强制重新生成(运维/调试用)。 */
51
+ catalogResetNonce: z.string().default(''),
52
+ })
53
+
54
+ /** 截断长描述,避免搜索结果膨胀。 */
55
+ function truncate(text, limit) {
56
+ const value = String(text ?? '').replace(/\s+/g, ' ').trim()
57
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
58
+ }
59
+
60
+ /** 跨插件实例的 restriction 登记(清理 HMR/重载遗留;兼容 v1 WeakMap)。 */
61
+ const REGISTRY = Symbol.for('@local/dsh-agentic-tool-catalog/restrictions')
62
+ /** 旧插件身份(重命名迁移用):清理旧实例/旧 bundle 留下的 restriction,避免工具表残缺。 */
63
+ const LEGACY_REGISTRY = Symbol.for('@local/dsh-tool-discovery/restrictions')
64
+
65
+ function restrictionRegistry() {
66
+ const existing = globalThis[REGISTRY]
67
+ if (existing instanceof Map) return existing
68
+ const registry = new Map()
69
+ globalThis[REGISTRY] = registry
70
+ return registry
71
+ }
72
+
73
+ function disposeEntry(entry) {
74
+ try {
75
+ if (typeof entry === 'function') entry()
76
+ else if (entry !== null && typeof entry === 'object' && typeof entry.dispose === 'function') entry.dispose()
77
+ } catch {}
78
+ }
79
+
80
+ const SHARED = Symbol.for('@local/dsh-agentic-tool-catalog/shared-v1')
81
+
82
+ /** 进程级共享状态:HMR 换插件实例时缓存/统计不丢(globalThis 跨模块实例保留)。 */
83
+ function sharedState() {
84
+ let shared = globalThis[SHARED]
85
+ if (shared === undefined) {
86
+ shared = {
87
+ instanceSeq: 0,
88
+ agentStats: {
89
+ runs: 0,
90
+ cacheHits: 0,
91
+ failures: 0,
92
+ retries: 0,
93
+ deduped: 0,
94
+ staleUses: 0,
95
+ incrementalRuns: 0,
96
+ lastError: null,
97
+ lastOkAt: null,
98
+ },
99
+ catalogCache: new Map(), // fingerprint -> 已构建目录(热路径)
100
+ groups: new Map(), // fingerprint -> 原始分组(来自官方 Storage / 本进程生成)
101
+ lastGood: null, // { fingerprint, groups, at }
102
+ failures: new Map(), // fingerprint -> 失败时间(退避)
103
+ inflight: new Map(), // fingerprint -> Promise<目录|null>(单飞)
104
+ lastReload: null,
105
+ }
106
+ globalThis[SHARED] = shared
107
+ }
108
+ return shared
109
+ }
110
+
111
+ /**
112
+ * 注册 search_tools / catalog_status,并按 agent 维护"常驻 + 按需"两类工具。
113
+ * @param ctx - 带 `tools` 服务的 profile 上下文。
114
+ * @param config - patch 行提供的配置。
115
+ */
116
+ export function apply(ctx, config) {
117
+ const instance = Symbol('instance')
118
+ const shared = sharedState()
119
+ const instanceId = ++shared.instanceSeq
120
+ // 新实例 = 新代码:不继承上一版留下的退避/暂停,否则热修后还要等 10 分钟才重试。
121
+ shared.failures.clear()
122
+ delete shared.agentStats.paused
123
+ const registry = restrictionRegistry()
124
+ const logger = ctx.logger
125
+ const bootAt = Date.now() // 区分“进程启动后新建会话”(预热)与“恢复会话”(按需)
126
+
127
+ for (const [agent, entry] of [...registry.entries()]) {
128
+ if (entry !== null && typeof entry === 'object' && entry.instance === instance) continue
129
+ disposeEntry(entry)
130
+ registry.delete(agent)
131
+ }
132
+ // 重命名迁移:旧 bundle 被 remove/替换时,其 restriction 可能没被旧实例 effect 清掉(实测孤儿限制),
133
+ // 这里主动释放旧全局登记表,避免新会话工具表只剩少数工具。
134
+ const legacy = globalThis[LEGACY_REGISTRY]
135
+ if (legacy instanceof Map) {
136
+ for (const [, entry] of [...legacy.entries()]) disposeEntry(entry)
137
+ legacy.clear()
138
+ }
139
+
140
+ const isResident = residentMatcher(config)
141
+ const maxResults = Math.max(1, Math.min(20, config.maxResults ?? 6))
142
+ const groupMaxResults = Math.max(1, config.groupMaxResults ?? 30)
143
+ const agentGrouper = createAgentGrouper(ctx, config, logger, shared.agentStats)
144
+ const store = createCatalogStore(ctx, shared, logger)
145
+ const resetNonce = typeof config.catalogResetNonce === 'string' ? config.catalogResetNonce : ''
146
+
147
+ /** SSE 订阅:sessionId → Set<ServerResponse>;只承载目录/会话状态快照,不推送正文。 */
148
+ const subscribers = new Map()
149
+ /** 每个 SSE 连接最后发送的 payload:相同快照不重复推送。 */
150
+ const lastEventPayload = new WeakMap()
151
+ /** 由 snapshotFor 注入;applyRestriction/rebuild 在运行时调用。 */
152
+ let publishSnapshot = () => {}
153
+ const sendEvent = (res, payload) => {
154
+ if (lastEventPayload.get(res) === payload) return
155
+ lastEventPayload.set(res, payload)
156
+ try { res.write(payload) } catch {}
157
+ }
158
+
159
+ const states = new WeakMap()
160
+ const stateOf = (agent) => {
161
+ let state = states.get(agent)
162
+ if (state === undefined) {
163
+ state = {
164
+ catalog: null,
165
+ loaded: new Set(),
166
+ denier: undefined,
167
+ denyKey: '',
168
+ warnings: [],
169
+ fingerprint: '',
170
+ source: 'bootstrap',
171
+ cacheOrigin: 'none',
172
+ activated: false,
173
+ needsEnrichment: false,
174
+ deferred: [],
175
+ resident: [],
176
+ enrichment: { pending: undefined, active: false, dirty: false },
177
+ }
178
+ states.set(agent, state)
179
+ }
180
+ return state
181
+ }
182
+
183
+ const disposeRestriction = (agent, state = states.get(agent)) => {
184
+ if (state?.denier !== undefined) {
185
+ try { state.denier() } catch {}
186
+ state.denier = undefined
187
+ state.denyKey = ''
188
+ }
189
+ const entry = registry.get(agent)
190
+ if (entry !== undefined) {
191
+ disposeEntry(entry)
192
+ registry.delete(agent)
193
+ }
194
+ }
195
+
196
+ /** 该 agent 可被 restriction 过滤的工具名(继承层;own-layer 不可过滤)。 */
197
+ const restrictableNamesOf = (agent) => {
198
+ const scope = scopeOf(agent.ctx)
199
+ if (scope !== undefined) {
200
+ try {
201
+ const view = ctx.tools.view(scope)
202
+ if (view?.restrictableNames instanceof Set) return view.restrictableNames
203
+ } catch {}
204
+ }
205
+ try { return new Set(ctx.tools.schemas().map((s) => s.name)) } catch { return new Set() }
206
+ }
207
+
208
+ /** 该 agent 自己的可见工具定义(含 preset 组合)。 */
209
+ const definitionsOf = (agent) => {
210
+ const scope = scopeOf(agent.ctx)
211
+ if (scope === undefined) return []
212
+ try {
213
+ if (typeof ctx.tools.definitions === 'function') return ctx.tools.definitions(scope)
214
+ return ctx.tools.schemas(scope)
215
+ } catch (error) {
216
+ logger?.warn?.(`dsh-agentic-tool-catalog: 读取 agent 工具表失败:${String(error?.message ?? error)}`)
217
+ return []
218
+ }
219
+ }
220
+
221
+ /** 目录指纹 = sha256(排序后的工具名 + 缓存命名空间)。只依赖名字,不依赖描述。 */
222
+ const fingerprintOf = (tools) => {
223
+ const names = tools.map((tool) => tool.name).sort().join('\n')
224
+ return createHash('sha256').update(`${resetNonce}\u0000${names}`).digest('hex')
225
+ }
226
+
227
+ /** 收敛可见工具集:deny = 目录 − 已加载。 */
228
+ const applyRestriction = (agent, state) => {
229
+ if (state.catalog === null) return
230
+ const catalog = state.catalog
231
+ const hidden = catalog.entries.map((e) => e.name).filter((toolName) => !state.loaded.has(toolName))
232
+ const key = hidden.slice().sort().join('\n')
233
+ if (key === state.denyKey) {
234
+ publishSnapshot(agent)
235
+ return
236
+ }
237
+ if (hidden.length === 0) {
238
+ disposeRestriction(agent, state)
239
+ publishSnapshot(agent)
240
+ return
241
+ }
242
+ try {
243
+ mutating = true
244
+ const dispose = agent.ctx.tools.restrict({ deny: hidden })
245
+ disposeRestriction(agent, state)
246
+ state.denier = dispose
247
+ state.denyKey = key
248
+ registry.set(agent, { instance, dispose })
249
+ mutating = false
250
+ } catch (error) {
251
+ mutating = false
252
+ state.denyKey = ''
253
+ logger?.warn?.(`dsh-agentic-tool-catalog: 收敛可见工具集失败(稍后重试):${String(error?.message ?? error)}`)
254
+ }
255
+ publishSnapshot(agent)
256
+ }
257
+
258
+ /** 由原始分组构建目录(并缓存到热路径)。 */
259
+ const catalogFor = (fingerprint, deferred, rawGroups, origin) => {
260
+ const cached = shared.catalogCache.get(fingerprint)
261
+ if (cached !== undefined) return cached
262
+ const checked = validateAgentGroups(deferred, { groups: rawGroups })
263
+ if (checked.error !== undefined) return undefined
264
+ const catalog = catalogFromAgentGroups(deferred, checked.groups)
265
+ catalog.source = 'agent'
266
+ shared.catalogCache.set(fingerprint, catalog)
267
+ return catalog
268
+ }
269
+
270
+ /**
271
+ * 目录增强:真子代理生成。目录变化时跑一次、按指纹缓存;
272
+ * 失败 → 自动重试(由 grouper 负责)→ 上次成功分组 → 扁平引导目录。
273
+ */
274
+ const startEnrichment = (agent, state, deferred, fingerprint, options = {}) => {
275
+ const allowGenerate = options.allowGenerate !== false
276
+ if (state.enrichment.active) return
277
+ if (deferred.length === 0) {
278
+ // 没有可分组的工具:不启动子代理(防御性,正常由 rebuild 的空目录分支拦下)。
279
+ state.needsEnrichment = false
280
+ return
281
+ }
282
+ const cacheKey = fingerprint
283
+ const isSubAgent = agent.session?.header?.origin === 'subagent'
284
+ if (isSubAgent && !shared.groups.has(cacheKey)) return
285
+
286
+ const applyCatalog = (enriched, origin = 'generated') => {
287
+ if (enriched === undefined) {
288
+ shared.agentStats.staleUses += 1
289
+ const lastGood = shared.lastGood
290
+ if (lastGood !== null && Array.isArray(lastGood.groups)) {
291
+ const adapted = adaptAgentGroups(deferred, lastGood.groups)
292
+ if (state.fingerprint !== fingerprint) return
293
+ state.catalog = adapted
294
+ state.source = 'agent-stale'
295
+ state.warnings = [...(adapted.warnings ?? []), { kind: 'catalog-stale-groups', message: `本次生成未成功,暂用最近一次成功分组(fingerprint ${String(lastGood.fingerprint).slice(0, 12)});后台会退避重试,详见 generation.lastError` }]
296
+ applyRestriction(agent, state)
297
+ logger?.warn?.(`dsh-agentic-tool-catalog: 生成未成功,暂用最近一次成功分组(${shared.agentStats.lastError?.code ?? 'UNKNOWN'})`)
298
+ return
299
+ }
300
+ state.source = 'bootstrap'
301
+ state.warnings = [...(state.catalog?.warnings ?? []), { kind: 'catalog-bootstrap', message: '生成未成功且无历史分组,暂用扁平目录;详见 generation.lastError' }]
302
+ return
303
+ }
304
+ if (state.fingerprint !== fingerprint) return
305
+ state.catalog = enriched
306
+ state.source = enriched.source ?? 'agent'
307
+ state.cacheOrigin = origin
308
+ state.warnings = [...(enriched.warnings ?? [])]
309
+ state.needsEnrichment = false
310
+ if (enriched.groups.length > 0) {
311
+ const lastGood = {
312
+ fingerprint,
313
+ groups: enriched.groups.map((group) => ({ id: group.id, description: group.description, tools: group.members })),
314
+ at: Date.now(),
315
+ }
316
+ store.saveLastGood(lastGood)
317
+ }
318
+ applyRestriction(agent, state)
319
+ publishSnapshot(agent)
320
+ }
321
+
322
+ // 1) 热路径缓存
323
+ const hot = shared.catalogCache.get(cacheKey)
324
+ if (hot !== undefined) {
325
+ shared.agentStats.cacheHits += 1
326
+ applyCatalog(hot, 'memory')
327
+ return
328
+ }
329
+ // 2) 官方 Storage hydrate 的原始分组
330
+ const raw = shared.groups.get(cacheKey)
331
+ if (raw !== undefined) {
332
+ const catalog = catalogFor(cacheKey, deferred, raw, 'storage')
333
+ if (catalog !== undefined) {
334
+ shared.agentStats.cacheHits += 1
335
+ applyCatalog(catalog, 'storage')
336
+ return
337
+ }
338
+ shared.groups.delete(cacheKey)
339
+ }
340
+ // Storage 还在 hydrate:先不生成,等 ready 后统一 syncAll
341
+ if (store.info.state === 'opening') {
342
+ state.needsEnrichment = true
343
+ return
344
+ }
345
+ // 软闸门:宿主连续拒绝 activation spec 后暂停自动生成,继续用 last-known-good;
346
+ // 到期由 generate() 自动解除,插件重装也会清掉(见 apply 顶部)。
347
+ const paused = shared.agentStats.paused
348
+ if (paused !== undefined && Date.now() < paused.retryAt) {
349
+ state.needsEnrichment = true
350
+ if (!state.warnings.some((entry) => entry.kind === 'catalog-generation-paused')) {
351
+ state.warnings = [...state.warnings, { kind: 'catalog-generation-paused', message: paused.message }]
352
+ }
353
+ return
354
+ }
355
+ const failedAt = shared.failures.get(cacheKey)
356
+ if (failedAt !== undefined && Date.now() - failedAt < 600000) return // 10 分钟退避
357
+
358
+ if (!allowGenerate) {
359
+ state.needsEnrichment = true
360
+ return
361
+ }
362
+
363
+ state.enrichment.active = true
364
+ publishSnapshot(agent)
365
+ const run = async () => {
366
+ const previousGroups = shared.lastGood?.groups
367
+ const generated = await agentGrouper.generate({ agent, tools: deferred, previousGroups })
368
+ if (generated.groups === undefined) {
369
+ shared.failures.set(cacheKey, Date.now())
370
+ return null
371
+ }
372
+ const catalog = catalogFromAgentGroups(deferred, generated.groups)
373
+ catalog.source = 'agent'
374
+ shared.catalogCache.set(cacheKey, catalog)
375
+ shared.failures.delete(cacheKey)
376
+ store.saveGroups(cacheKey, generated.groups.map((group) => ({ id: group.id, description: group.description, tools: group.members })))
377
+ return catalog
378
+ }
379
+ let generation = shared.inflight.get(cacheKey)
380
+ if (generation === undefined) {
381
+ generation = run()
382
+ shared.inflight.set(cacheKey, generation)
383
+ generation.finally(() => shared.inflight.delete(cacheKey)).catch(() => {})
384
+ } else {
385
+ shared.agentStats.deduped += 1
386
+ }
387
+ const settle = (enriched) => {
388
+ state.enrichment.active = false
389
+ state.enrichment.pending = undefined
390
+ publishSnapshot(agent)
391
+ applyCatalog(enriched ?? undefined)
392
+ const dirty = state.enrichment.dirty === true
393
+ state.enrichment.dirty = false
394
+ if (dirty && state.fingerprint !== '' && state.fingerprint !== fingerprint) {
395
+ startEnrichment(agent, state, state.deferred, state.fingerprint, { allowGenerate: true })
396
+ }
397
+ }
398
+ state.enrichment.pending = generation.then((enriched) => { settle(enriched); return enriched }).catch(() => { settle(undefined); return undefined })
399
+ }
400
+
401
+ /** 重建该 agent 的目录:释放旧 restriction → 读全量定义 → 引导目录/上次成功分组 → 缓存/生成。 */
402
+ const rebuild = (agent, force = false) => {
403
+ const state = stateOf(agent)
404
+ mutating = true
405
+ disposeRestriction(agent, state)
406
+ mutating = false
407
+ const restrictable = restrictableNamesOf(agent)
408
+ const definitions = definitionsOf(agent)
409
+ const deferred = definitions
410
+ .filter((definition) => restrictable.has(definition.name))
411
+ .filter((definition) => !isResident(definition.name))
412
+ .map((definition) => ({ name: definition.name, description: String(definition.description ?? '') }))
413
+ const deferredNames = new Set(deferred.map((definition) => definition.name))
414
+ state.resident = definitions
415
+ .filter((definition) => !deferredNames.has(definition.name))
416
+ .map((definition) => definition.name)
417
+ const fingerprint = fingerprintOf(deferred)
418
+ if (!force && state.catalog !== null && state.fingerprint === fingerprint) {
419
+ applyRestriction(agent, state)
420
+ publishSnapshot(agent)
421
+ return
422
+ }
423
+ if (deferred.length === 0) {
424
+ // 空目录(工具全部常驻/已加载):没有可分组的对象,直接给空目录并跳过生成。
425
+ // 否则每次都会启动子代理去分 0 个工具,必然失败在 TOO_FEW_GROUPS / EMPTY_GROUP(2026-11-11 实测)。
426
+ if (state.enrichment.active && state.fingerprint !== fingerprint) state.enrichment.dirty = true
427
+ state.catalog = { strategy: 'agentic-tool-catalog', source: 'agent', entries: [], groups: [], others: [], warnings: [] }
428
+ state.source = 'agent'
429
+ state.warnings = []
430
+ state.fingerprint = fingerprint
431
+ state.deferred = []
432
+ state.needsEnrichment = false
433
+ applyRestriction(agent, state)
434
+ return
435
+ }
436
+ const lastGood = shared.lastGood
437
+ let catalog
438
+ if (lastGood !== null && Array.isArray(lastGood.groups) && lastGood.groups.length > 0) {
439
+ catalog = adaptAgentGroups(deferred, lastGood.groups)
440
+ catalog.strategy = 'agentic-tool-catalog'
441
+ catalog.warnings = [...(catalog.warnings ?? []), { kind: 'catalog-refreshing', message: '目录变化:暂用最近一次成功分组并后台重新生成' }]
442
+ } else {
443
+ catalog = buildBootstrapCatalog(deferred)
444
+ }
445
+ state.catalog = catalog
446
+ state.source = catalog.source ?? 'bootstrap'
447
+ state.warnings = [...(catalog.warnings ?? [])]
448
+ if (state.enrichment.active && state.fingerprint !== fingerprint) state.enrichment.dirty = true
449
+ state.fingerprint = fingerprint
450
+ state.deferred = deferred
451
+ applyRestriction(agent, state)
452
+ const isNewSession = (agent.session?.header?.createdAt ?? 0) >= bootAt
453
+ const eager = isNewSession || state.activated
454
+ state.needsEnrichment = true
455
+ startEnrichment(agent, state, deferred, fingerprint, { allowGenerate: eager })
456
+ publishSnapshot(agent)
457
+ }
458
+
459
+ /** 真正需要时才触发生成(恢复会话的首个请求/搜索;新会话在创建时已预热)。 */
460
+ const ensureEnrichment = (agent) => {
461
+ const state = states.get(agent)
462
+ if (state === undefined) return
463
+ state.activated = true
464
+ if (state.catalog === null) return
465
+ if (state.enrichment.active) return
466
+ if (state.needsEnrichment) startEnrichment(agent, state, state.deferred, state.fingerprint, { allowGenerate: true })
467
+ }
468
+
469
+ let applying = false
470
+ let mutating = false
471
+ const isMutating = () => applying || mutating
472
+ const sync = (agent, force = false) => {
473
+ if (isMutating()) return
474
+ applying = true
475
+ try { rebuild(agent, force) } finally { applying = false }
476
+ }
477
+ const syncAll = (force = false) => {
478
+ const agents = ctx.get('agents')
479
+ if (agents === undefined) return
480
+ for (const agent of agents.list()) sync(agent, force)
481
+ }
482
+
483
+ // Storage hydrate 完成后,把缓存目录应用到所有 agent(首次启动的磁盘命中在这里生效)。
484
+ store.whenReady().then((ok) => {
485
+ if (!ok) return
486
+ shared.catalogCache.clear()
487
+ syncAll(true)
488
+ }).catch(() => {})
489
+
490
+ // ── 只读状态快照(catalog_status 工具 + Client dock 共用) ─────────────────
491
+ const snapshotFor = (agent, state, options = {}) => {
492
+ const catalog = state?.catalog
493
+ const loaded = state === undefined ? new Set() : state.loaded
494
+ const groups = (catalog?.groups ?? [])
495
+ .map((group) => {
496
+ const entry = { id: group.id, count: group.members.filter((name) => !loaded.has(name)).length }
497
+ if (options.withDescriptions === true) entry.description = truncate(group.description, 160)
498
+ return entry
499
+ })
500
+ .filter((group) => group.count > 0)
501
+ const pendingOthers = (catalog?.others ?? []).filter((name) => !loaded.has(name)).length
502
+ const active = state?.enrichment?.active === true
503
+ const freshness = state === undefined
504
+ ? 'none'
505
+ : state.source === 'bootstrap'
506
+ ? (active ? 'refreshing' : 'bootstrap')
507
+ : state.source === 'agent-stale'
508
+ ? (active ? 'refreshing' : 'stale')
509
+ : (active ? 'refreshing' : 'fresh')
510
+ return {
511
+ plugin: 'agentic-tool-catalog',
512
+ catalog: {
513
+ source: state?.source ?? 'none',
514
+ freshness,
515
+ origin: state?.cacheOrigin ?? 'none',
516
+ total: catalog?.entries.length ?? 0,
517
+ resident: state?.resident?.length ?? 0,
518
+ groups,
519
+ others: pendingOthers,
520
+ },
521
+ session: {
522
+ loaded: [...loaded].sort(),
523
+ pending: catalog === null || state === undefined ? null : catalog.entries.length - loaded.size,
524
+ restriction: state === undefined
525
+ ? 'no-agent'
526
+ : state.denier === undefined ? 'none' : `deny ${(state.denyKey ?? '').split('\n').filter(Boolean).length} tools`,
527
+ resident: `${(config.resident?.length ?? 0) > 0 ? `${config.resident.length} (override)` : 'builtin'} + ${config.extraKeepResident?.length ?? 0} extra`,
528
+ },
529
+ generation: {
530
+ provider: agentGrouper.provider,
531
+ enabled: agentGrouper.enabled(),
532
+ activationShapes: agentGrouper.activationShapes,
533
+ state: active ? 'running' : 'idle',
534
+ ...shared.agentStats,
535
+ instanceId,
536
+ lastReload: shared.lastReload,
537
+ },
538
+ storage: store.status(),
539
+ warnings: state?.warnings ?? [],
540
+ }
541
+ }
542
+
543
+ // ── search_tools:唯一的常驻入口 ─────────────────────────────────────────
544
+ const description = [
545
+ 'Search the agent-curated on-demand tool catalog and load matching tools for this session.',
546
+ 'Most integration and management tools are NOT loaded by default (MCP servers, DSH self-management, browser automation, reminders, goals, workflows, and other extensions).',
547
+ 'Before using one of those capabilities, search here with an intent or keywords; matching tools become callable directly for the rest of this session.',
548
+ 'If the query exactly matches a catalog group name, the whole group loads at once (up to the configured group limit).',
549
+ ].join(' ')
550
+
551
+ ctx.tools.register(defineTool({
552
+ name: 'search_tools',
553
+ description,
554
+ parameters: {
555
+ query: {
556
+ type: 'string',
557
+ description: 'What capability you need, or keywords from a tool name/description (e.g. "cloudflare dns", "gmail send", "archive session", "browser screenshot", "reminder"), or an exact catalog group name.',
558
+ },
559
+ limit: {
560
+ type: 'number',
561
+ description: `Maximum number of tools to load; defaults to ${maxResults}, capped at 20.`,
562
+ },
563
+ },
564
+ output: {
565
+ schema: { type: 'string' },
566
+ render: (_args, value) => [{ type: 'text', text: value }],
567
+ },
568
+ async execute(args, exec) {
569
+ const agent = exec.agent
570
+ if (agent === undefined) return 'search_tools can only run inside an agent session.'
571
+ sync(agent)
572
+ ensureEnrichment(agent)
573
+ const state = stateOf(agent)
574
+ const catalog = state.catalog
575
+ if (catalog === null || catalog.entries.length === 0) {
576
+ return 'No on-demand capability tools are registered for this session.'
577
+ }
578
+ const pending = catalog.entries.length - state.loaded.size
579
+ if (pending <= 0) {
580
+ return 'All on-demand capability tools are already loaded for this session. Call them directly, or use catalog_status to inspect the catalog.'
581
+ }
582
+ const query = String(args.query ?? '').trim()
583
+ const limit = Math.max(1, Math.min(20, Math.trunc(args.limit ?? maxResults)))
584
+ const result = rankTools(catalog, query, { limit, groupMaxResults, loaded: state.loaded })
585
+ if (result.loaded.length === 0) {
586
+ return `No deferred tool matches "${query}". ${state.warnings.map((w) => w.message).join('; ')}\nSearch again with keywords from the on-demand groups, or use catalog_status for details.`
587
+ }
588
+ for (const toolName of result.loaded) state.loaded.add(toolName)
589
+ applyRestriction(agent, state)
590
+ const byName = new Map(catalog.entries.map((e) => [e.name, e]))
591
+ const lines = result.loaded.map((toolName) => `- ${toolName}: ${truncate(byName.get(toolName)?.description, 240)}`)
592
+ const head = result.groupLoad
593
+ ? `Loaded ${result.loaded.length} tool(s) from group "${result.groupId}" for this session — call them directly now:`
594
+ : `Loaded ${result.loaded.length} tool(s) for this session — call them directly now:`
595
+ const remaining = result.remainingInGroup > 0
596
+ ? `\n${result.remainingInGroup} more tool(s) in this group did not fit (groupMaxResults=${groupMaxResults}); search again with narrower keywords to load them.`
597
+ : ''
598
+ const more = result.more > 0
599
+ ? `\n${result.more} more tool(s) matched; search again with a narrower query (or a higher limit) to load them.`
600
+ : ''
601
+ return `${head}\n${lines.join('\n')}${remaining}${more}`
602
+ },
603
+ }))
604
+
605
+ // ── catalog_status:只读观测入口 ─────────────────────────────────────────
606
+ ctx.tools.register(defineTool({
607
+ name: 'catalog_status',
608
+ description: 'Read-only status of the agent-curated on-demand tool catalog: catalog freshness/origin, groups, loaded tools, generation/cache health, storage mode, warnings and restriction health. Use when debugging why a capability cannot be found or loaded.',
609
+ parameters: {},
610
+ output: {
611
+ schema: { type: 'string' },
612
+ render: (_args, value) => [{ type: 'text', text: value }],
613
+ },
614
+ async execute(_args, exec) {
615
+ const agent = exec.agent
616
+ const state = agent === undefined ? undefined : states.get(agent)
617
+ return JSON.stringify(snapshotFor(agent, state), null, 2)
618
+ },
619
+ }))
620
+
621
+ // ── SSE:目录/会话状态变化事件驱动推送(面板不再轮询) ────────────────────
622
+ const EVENTS_ROUTE = '/plugins/agentic-tool-catalog/events'
623
+ publishSnapshot = (agent) => {
624
+ const sessionId = agent?.session?.header?.id ?? agent?.session?.id
625
+ if (typeof sessionId !== 'string' || sessionId === '') return
626
+ const set = subscribers.get(sessionId)
627
+ if (set === undefined || set.size === 0) return
628
+ let payload
629
+ try {
630
+ payload = `event: snapshot\ndata: ${JSON.stringify({ v: 1, snapshot: snapshotFor(agent, states.get(agent), { withDescriptions: true }) })}\n\n`
631
+ } catch { return }
632
+ for (const res of [...set]) {
633
+ sendEvent(res, payload)
634
+ }
635
+ }
636
+
637
+ // ── Client dock 只读状态路由(可选 webServer;CLI/profile 无页面时静默跳过) ──
638
+ const STATUS_ROUTE = '/plugins/agentic-tool-catalog/snapshot'
639
+ const isTrustedRequest = (req) => {
640
+ const site = req.headers['sec-fetch-site']
641
+ if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false
642
+ const origin = req.headers.origin
643
+ if (typeof origin !== 'string' || origin === '' || origin === 'null') return true
644
+ try {
645
+ const url = new URL(origin)
646
+ if (url.protocol === 'dsh-app:') return true
647
+ const host = req.headers.host
648
+ return host !== undefined && url.host === host
649
+ } catch { return false }
650
+ }
651
+ try {
652
+ ctx.inject(['webServer'], (scope) => {
653
+ const disposeRoute = scope.webServer.register({
654
+ kind: 'exact',
655
+ path: STATUS_ROUTE,
656
+ handler: (req, res) => {
657
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
658
+ res.writeHead(405, { 'content-type': 'application/json' })
659
+ res.end(JSON.stringify({ v: 1, error: 'method-not-allowed' }))
660
+ return
661
+ }
662
+ if (!isTrustedRequest(req)) {
663
+ res.writeHead(403, { 'content-type': 'application/json' })
664
+ res.end(JSON.stringify({ v: 1, error: 'forbidden' }))
665
+ return
666
+ }
667
+ let sessionId = ''
668
+ try { sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId') ?? '' } catch {}
669
+ if (sessionId === '') {
670
+ res.writeHead(400, { 'content-type': 'application/json' })
671
+ res.end(JSON.stringify({ v: 1, error: 'missing-session-id' }))
672
+ return
673
+ }
674
+ const agents = ctx.get('agents')
675
+ const agent = agents?.list?.().find((candidate) => (candidate.session?.header?.id ?? candidate.session?.id) === sessionId)
676
+ if (agent === undefined) {
677
+ res.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
678
+ res.end(JSON.stringify({ v: 1, error: 'session-not-found' }))
679
+ return
680
+ }
681
+ const snapshot = snapshotFor(agent, states.get(agent), { withDescriptions: true })
682
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
683
+ res.end(JSON.stringify({ v: 1, snapshot }))
684
+ },
685
+ })
686
+ scope.effect(() => () => {
687
+ try { disposeRoute() } catch {}
688
+ }, 'agentic-tool-catalog: snapshot route')
689
+
690
+ const disposeEvents = scope.webServer.register({
691
+ kind: 'exact',
692
+ path: EVENTS_ROUTE,
693
+ handler: (req, res) => {
694
+ if (req.method !== 'GET') {
695
+ res.writeHead(405, { 'content-type': 'application/json' })
696
+ res.end(JSON.stringify({ v: 1, error: 'method-not-allowed' }))
697
+ return
698
+ }
699
+ if (!isTrustedRequest(req)) {
700
+ res.writeHead(403, { 'content-type': 'application/json' })
701
+ res.end(JSON.stringify({ v: 1, error: 'forbidden' }))
702
+ return
703
+ }
704
+ let sessionId = ''
705
+ try { sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId') ?? '' } catch {}
706
+ const agents = ctx.get('agents')
707
+ const agent = agents?.list?.().find((candidate) => (candidate.session?.header?.id ?? candidate.session?.id) === sessionId)
708
+ if (sessionId === '' || agent === undefined) {
709
+ res.writeHead(sessionId === '' ? 400 : 404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
710
+ res.end(JSON.stringify({ v: 1, error: sessionId === '' ? 'missing-session-id' : 'session-not-found' }))
711
+ return
712
+ }
713
+ res.writeHead(200, {
714
+ 'content-type': 'text/event-stream',
715
+ 'cache-control': 'no-cache, no-transform',
716
+ connection: 'keep-alive',
717
+ 'x-accel-buffering': 'no',
718
+ })
719
+ res.write(': connected\n\n')
720
+ try {
721
+ sendEvent(res, `event: snapshot\ndata: ${JSON.stringify({ v: 1, snapshot: snapshotFor(agent, states.get(agent), { withDescriptions: true }) })}\n\n`)
722
+ } catch {}
723
+ let set = subscribers.get(sessionId)
724
+ if (set === undefined) {
725
+ set = new Set()
726
+ subscribers.set(sessionId, set)
727
+ }
728
+ set.add(res)
729
+ const heartbeat = setInterval(() => {
730
+ try { res.write(': ping\n\n') } catch {}
731
+ }, 25000)
732
+ res.on('close', () => {
733
+ clearInterval(heartbeat)
734
+ set.delete(res)
735
+ if (set.size === 0) subscribers.delete(sessionId)
736
+ })
737
+ },
738
+ })
739
+ scope.effect(() => () => {
740
+ try { disposeEvents() } catch {}
741
+ for (const [sessionId, set] of [...subscribers.entries()]) {
742
+ for (const res of [...set]) {
743
+ try { res.end() } catch {}
744
+ }
745
+ subscribers.delete(sessionId)
746
+ }
747
+ }, 'agentic-tool-catalog: events route')
748
+ })
749
+ } catch (error) {
750
+ logger?.warn?.(`dsh-agentic-tool-catalog: status route unavailable (${String(error?.message ?? error)})`)
751
+ }
752
+
753
+ // ── 重载观测 ────────────────────────────────────────────────────────────
754
+ for (const event of ['hmr/reload', 'plugin-manager/changed', 'app-boot/config-reload']) {
755
+ try { ctx.on(event, () => { shared.lastReload = { event, at: Date.now() } }) } catch {}
756
+ }
757
+
758
+ // ── 按 agent 收敛可见工具集 ──────────────────────────────────────────────
759
+ ctx.on('agent/created', ({ agent }) => sync(agent, true))
760
+ ctx.on('agent/inbox/claimed', ({ agent }) => ensureEnrichment(agent))
761
+ ctx.on('agent/disposed', ({ agent }) => {
762
+ disposeRestriction(agent)
763
+ states.delete(agent)
764
+ })
765
+ ctx.on('agent-preset/selected', () => syncAll(true))
766
+ ctx.on('tools/change', () => {
767
+ if (isMutating()) return
768
+ syncAll(false)
769
+ })
770
+ syncAll(true)
771
+ ctx.effect(() => () => {
772
+ for (const [agent, entry] of [...registry.entries()]) {
773
+ if (entry !== null && typeof entry === 'object' && entry.instance !== instance) continue
774
+ disposeEntry(entry)
775
+ registry.delete(agent)
776
+ }
777
+ const agents = ctx.get('agents')
778
+ if (agents !== undefined) for (const agent of agents.list()) disposeRestriction(agent)
779
+ })
780
+
781
+ // ── 系统提示里的一条简短目录提示 ─────────────────────────────────────────
782
+ const systemPrompt = ctx.get('systemPrompt')
783
+ if (config.promptNote !== false && systemPrompt !== undefined) {
784
+ systemPrompt.context({
785
+ name: 'catalog:note',
786
+ order: 300,
787
+ text: (assemblyContext) => {
788
+ const agent = assemblyContext?.agent
789
+ const state = agent === undefined ? undefined : states.get(agent)
790
+ if (state?.catalog === undefined || state?.catalog === null) return ''
791
+ return noteText(state.catalog, state.loaded)
792
+ },
793
+ })
794
+ }
795
+ }