@whaletalk/dsh-codex-collab 0.1.6 → 0.1.7
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/README.md +11 -2
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +9 -2
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +41 -4
- package/harness/dsh-bridge.mjs +151 -11
- package/harness/session-target.mjs +53 -0
- package/mcp/dsh-mcp.mjs +14 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -218,8 +218,9 @@ dsh_task({ "instruction": "继续推进第 3 步…", "sessionId": "session-8d48
|
|
|
218
218
|
# 新建子代理(默认行为)
|
|
219
219
|
dsh-task --in "<指令>" --cwd "<目录>" [--lane backend] [--model fast|pro] [--commit]
|
|
220
220
|
|
|
221
|
-
#
|
|
221
|
+
# 接进已有会话:先找(或先空跑验证),再派
|
|
222
222
|
dsh-task --sessions "<标题关键词>" # 只读列候选,拿 sessionId
|
|
223
|
+
dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中,不投递任何消息
|
|
223
224
|
dsh-task --in "<指令>" --session "<sessionId>" [--steer]
|
|
224
225
|
dsh-task --in "<指令>" --find "<标题关键词>" # 唯一命中才派;否则退出码 4
|
|
225
226
|
|
|
@@ -235,13 +236,21 @@ dsh-task --sessions "按文档启动OKX策略实验计划"
|
|
|
235
236
|
# → {"items":[{"sessionId":"session-8d481ad9-…","snippet":"按文档启动OKX策略实验计划",
|
|
236
237
|
# "agentAvailable":true}],"total":1,"matchedBy":"list"}
|
|
237
238
|
|
|
239
|
+
# 1b) 没命中时会给近似候选(按标题相似度 + 最近更新排序),换词重查或改用 sessionId——
|
|
240
|
+
# 绝不要发一条消息去"试探命中"(它会落进对方会话并跑成回合,污染对话)
|
|
241
|
+
dsh-task --dry-run --find "量化框架"
|
|
242
|
+
# → {"resolved":false,"reason":"session-query-empty",
|
|
243
|
+
# "candidates":[{"sessionId":"1c0f99f8-…","snippet":"你是一个量化研究项目的技术","score":0.333}, …]}
|
|
244
|
+
|
|
238
245
|
# 2) 接通(投递进那条会话;这一轮的助手回复作为汇报返回,只回报本轮新增文本)
|
|
239
246
|
dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-…"
|
|
240
247
|
```
|
|
241
248
|
|
|
242
249
|
`matchedBy` 说明"找到"走了哪条路:`search` = 会话搜索索引;`list` = 索引被部署禁用时退回遍历列表 + 标题投影(都不激活 Agent)。
|
|
243
250
|
|
|
244
|
-
|
|
251
|
+
**`delivered` 与排队语义**(重要):`prompt` 一旦被目标会话接受,就表示消息**已经进了它的队列**,响应里会带 `delivered: true`;此后即便我们的观察超时(会话可能正跑一个很长的回合),也**不要重发**——重发会让同一份内容在用户会话里出现两次。`queue` 模式不再用 120s 判失败:没在 120s 内开跑只记一条 note 并继续等,最终由总预算(默认 1h)收口为 `session-timeout` + `delivered: true`。
|
|
252
|
+
|
|
253
|
+
失败返回稳定 `reason` 且**不创建任何会话/工作区**:`session-query-empty`(没命中,附 `candidates`)、`session-ambiguous`(多条命中,附 `candidates`)、`session-not-found`、`session-busy`、`session-writer-held`(会话被别的写入方占用)、`session-archived`、`session-not-accepted`(仅当 prompt **未被接受**)、`session-timeout`、`session-cancel-refused`(取消会话目标默认被拒,需 `--force`)。
|
|
245
254
|
|
|
246
255
|
## 反向通道:让 DSH 调用 Codex
|
|
247
256
|
|
|
@@ -30,10 +30,17 @@ description: "与 DeepSeek Harness 编码协作。当用户要写代码、建项
|
|
|
30
30
|
## 找到已有会话(只读,不创建任何东西)
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
dsh-task --sessions "<标题或内容关键词>"
|
|
33
|
+
dsh-task --sessions "<标题或内容关键词>" # 只读列出候选
|
|
34
|
+
dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中结果,不投递
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id
|
|
37
|
+
输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id>`。
|
|
38
|
+
|
|
39
|
+
没命中时会额外给出 `candidates`(按标题相似度 + 最近更新排序)——**换词重查,或直接确认用户指的是哪一条**。
|
|
40
|
+
|
|
41
|
+
> ⛔ **绝不投递任何消息来"验证命中"**(例如发一条「占位/测试/忽略本条」)。真实事故:调用方搜不到标题后发了条占位消息试探,它解析成了用户自己的会话,于是**作为一条真实用户消息落进对话并跑成回合**,会话里的 agent 还以为那是用户发的。验证命中只有两种合法手段:上面的 `--sessions` 和 `--dry-run`。
|
|
42
|
+
>
|
|
43
|
+
> ⛔ **看到 `delivered: true` 就不要重发**。它表示 prompt 已被那条会话接受、进了它的队列;我们只是没等到它开跑(会话可能正忙着一个很长的回合)。重发会让同一份内容在用户会话里出现两次。用 `--status <taskId>` 继续观察。
|
|
37
44
|
|
|
38
45
|
## 下发任务
|
|
39
46
|
|
|
@@ -41,6 +41,7 @@ function parseArgs(argv) {
|
|
|
41
41
|
else if (a === '--session') { out.sessionId = argv[++i] || '' }
|
|
42
42
|
else if (a === '--find') { out.find = argv[++i] || '' }
|
|
43
43
|
else if (a === '--steer') { out.steer = true }
|
|
44
|
+
else if (a === '--dry-run') { out.dryRun = true }
|
|
44
45
|
else if (a === '--force') { out.force = true }
|
|
45
46
|
else { rest.push(a) }
|
|
46
47
|
}
|
|
@@ -175,22 +176,53 @@ async function main() {
|
|
|
175
176
|
}
|
|
176
177
|
console.log(JSON.stringify(res.json, null, 2))
|
|
177
178
|
const items = Array.isArray(res.json.items) ? res.json.items : []
|
|
179
|
+
if (items.length === 0 && Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
|
|
180
|
+
console.error('未命中。候选(改用 --session <id>,或换个关键词):')
|
|
181
|
+
for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
|
|
182
|
+
console.error('提示: 验证命中用 --dry-run,不要投递消息去试探。')
|
|
183
|
+
}
|
|
178
184
|
finish(items.length > 0 ? 0 : 4)
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
const instruction = opts.instruction.trim()
|
|
188
|
+
const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
|
|
189
|
+
const sessionId = (opts.sessionId || '').trim()
|
|
190
|
+
const find = (opts.find || '').trim()
|
|
191
|
+
const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
|
|
192
|
+
|
|
193
|
+
// --dry-run:只解析目标、回显命中结果,什么都不投递。验证"这条查询会命中哪条会话"就用它,
|
|
194
|
+
// 绝不要投递一条消息去试探(那会落进用户会话并跑成回合,污染对话)。
|
|
195
|
+
if (opts.dryRun) {
|
|
196
|
+
if (instruction === '' && sessionTarget === null && cwd === '') {
|
|
197
|
+
console.error('用法: node task.mjs --dry-run (--find "<标题关键词>" | --session <会话id> | --cwd <目录>)')
|
|
198
|
+
finish(2)
|
|
199
|
+
}
|
|
200
|
+
const res = await request('POST', GATEWAY + '/api/dsh-bridge/task', {
|
|
201
|
+
instruction: instruction === '' ? '(dry-run)' : instruction,
|
|
202
|
+
dryRun: true,
|
|
203
|
+
...(sessionTarget === null ? (cwd ? { cwd } : {}) : sessionTarget),
|
|
204
|
+
})
|
|
205
|
+
console.log(JSON.stringify(res.json, null, 2))
|
|
206
|
+
if (res.json === null || typeof res.json !== 'object') finish(1)
|
|
207
|
+
if (res.json.resolved !== true) {
|
|
208
|
+
if (Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
|
|
209
|
+
console.error('未命中。候选(改用 --session <id>,或换个关键词):')
|
|
210
|
+
for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
|
|
211
|
+
}
|
|
212
|
+
finish(4)
|
|
213
|
+
}
|
|
214
|
+
finish(0)
|
|
215
|
+
}
|
|
216
|
+
|
|
182
217
|
if (!instruction) {
|
|
183
218
|
console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait]')
|
|
184
219
|
console.error(' node task.mjs --in "<指令>" (--session <会话id> | --find "<标题关键词>") [--steer] # 投递进已有会话')
|
|
185
220
|
console.error('查找: node task.mjs --sessions "<标题关键词>" # 只读列出候选, 拿 sessionId 再派活')
|
|
221
|
+
console.error(' node task.mjs --dry-run (--find "<关键词>" | --session <id>) # 只解析目标, 不投递(验证命中用这个)')
|
|
186
222
|
console.error('管理: node task.mjs --list | --status <taskId> | --cancel <taskId> [--force]')
|
|
187
223
|
console.error('退出码: 0 成功 / 1 失败 / 2 用法 / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒')
|
|
188
224
|
finish(2)
|
|
189
225
|
}
|
|
190
|
-
const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
|
|
191
|
-
const sessionId = (opts.sessionId || '').trim()
|
|
192
|
-
const find = (opts.find || '').trim()
|
|
193
|
-
const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
|
|
194
226
|
if (sessionTarget !== null && (cwd !== '' || opts.commit)) {
|
|
195
227
|
console.error('task.mjs: --session/--find 不能与 --cwd/--commit 同时使用(已有会话自带工作目录,本工具不替它提交)')
|
|
196
228
|
finish(2)
|
|
@@ -246,6 +278,11 @@ async function main() {
|
|
|
246
278
|
console.log(JSON.stringify({ phase: 'finished', ...status }))
|
|
247
279
|
console.log('=== DeepSeek 汇报 ===')
|
|
248
280
|
console.log(status.result || status.error || '(无内容)')
|
|
281
|
+
// delivered=true 表示 prompt 已被那条会话接受(进了它的队列):即便我们等超时了,
|
|
282
|
+
// 消息也可能会跑,**不要重发**(重发会让同一份内容在用户会话里出现两次)。
|
|
283
|
+
if (status.status === 'error' && status.delivered === true) {
|
|
284
|
+
console.error('注意: 消息已投递进该会话的队列(delivered=true),只是我们没等到它开跑。不要重发;可用 --status ' + task.taskId + ' 继续观察。')
|
|
285
|
+
}
|
|
249
286
|
if (status.status === 'done') {
|
|
250
287
|
// session 目标的记忆就在那条会话里,不再往协作历史里塞一份。
|
|
251
288
|
if (sessionTarget === null) {
|
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
mapControllerError,
|
|
33
33
|
sessionTitleOf,
|
|
34
34
|
looksLikeDisabledSearch,
|
|
35
|
+
rankTitleCandidates,
|
|
35
36
|
taskResponse,
|
|
36
37
|
} from './session-target.mjs'
|
|
37
38
|
|
|
@@ -449,6 +450,33 @@ export async function apply(ctx) {
|
|
|
449
450
|
}
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
/**
|
|
454
|
+
* 查询没命中时给的"近似候选":读 list(不激活 Agent、不开索引),按 bigram 相似度
|
|
455
|
+
* 与最近更新排序。存在的意义是**不给"探测"留动机**——真实事故里调用方搜不到标题后
|
|
456
|
+
* 发了一条占位消息去试探,那条消息落进用户会话并跑成回合。有了候选,只读就能改词重查。
|
|
457
|
+
*/
|
|
458
|
+
const sessionNearMisses = async (query, limit = 5) => {
|
|
459
|
+
const controller = sessionControllerNow()
|
|
460
|
+
if (controller === undefined) return []
|
|
461
|
+
try {
|
|
462
|
+
const value = await controller.list({}, signalOf())
|
|
463
|
+
const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
|
|
464
|
+
const summaries = []
|
|
465
|
+
for (const summary of items) {
|
|
466
|
+
if (summary === null || typeof summary !== 'object' || typeof summary.sessionId !== 'string') continue
|
|
467
|
+
summaries.push({
|
|
468
|
+
sessionId: summary.sessionId,
|
|
469
|
+
title: sessionTitleOf(summary),
|
|
470
|
+
updatedAt: typeof summary.updatedAt === 'number' ? summary.updatedAt : 0,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
return rankTitleCandidates(query, summaries, limit)
|
|
474
|
+
} catch (e) {
|
|
475
|
+
console.error('[dsh-bridge] near-miss lookup failed:', errText(e).slice(0, 200))
|
|
476
|
+
return []
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
452
480
|
/** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
|
|
453
481
|
const resolveSessionTarget = async (target) => {
|
|
454
482
|
const controller = sessionControllerNow()
|
|
@@ -467,7 +495,13 @@ export async function apply(ctx) {
|
|
|
467
495
|
}
|
|
468
496
|
}
|
|
469
497
|
const picked = selectSession(hits, target)
|
|
470
|
-
if (!picked.ok)
|
|
498
|
+
if (!picked.ok) {
|
|
499
|
+
// 查询落空不是"死路":附上近似候选,调用方只读就能改词重查(别再发消息探测)。
|
|
500
|
+
if (picked.reason === REASONS.QUERY_EMPTY) {
|
|
501
|
+
return { ...picked, candidates: await sessionNearMisses(target.query) }
|
|
502
|
+
}
|
|
503
|
+
return picked
|
|
504
|
+
}
|
|
471
505
|
let result
|
|
472
506
|
try {
|
|
473
507
|
result = await controller.resolveAgent(picked.sessionId)
|
|
@@ -494,6 +528,7 @@ export async function apply(ctx) {
|
|
|
494
528
|
if (fields.error !== undefined) task.error = fields.error
|
|
495
529
|
if (fields.reason !== undefined) task.reason = fields.reason
|
|
496
530
|
if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
|
|
531
|
+
if (fields.delivered !== undefined) task.delivered = fields.delivered
|
|
497
532
|
pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
|
|
498
533
|
...(task.result === undefined ? {} : { text: task.result }),
|
|
499
534
|
...(task.error === undefined ? {} : { error: task.error }),
|
|
@@ -603,6 +638,8 @@ export async function apply(ctx) {
|
|
|
603
638
|
sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
|
|
604
639
|
total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
|
|
605
640
|
settled: false, abort: undefined, watcher: 'pending',
|
|
641
|
+
// prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
|
|
642
|
+
delivered: false, queuedNoted: false,
|
|
606
643
|
}
|
|
607
644
|
sessionTargets.set(resolved.sessionId, record)
|
|
608
645
|
|
|
@@ -635,7 +672,9 @@ export async function apply(ctx) {
|
|
|
635
672
|
}
|
|
636
673
|
|
|
637
674
|
watchSessionTask(record)
|
|
638
|
-
|
|
675
|
+
record.delivered = true
|
|
676
|
+
task.delivered = true
|
|
677
|
+
pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet, delivered: true })
|
|
639
678
|
return { task, failed: null }
|
|
640
679
|
}
|
|
641
680
|
|
|
@@ -770,16 +809,39 @@ export async function apply(ctx) {
|
|
|
770
809
|
}
|
|
771
810
|
} catch (e) { /* 读不到就下一轮再试 */ }
|
|
772
811
|
}
|
|
812
|
+
// 派发超时只在"prompt 没被接受"时才算失败。
|
|
813
|
+
// queue 模式下 prompt 被接受就意味着消息已进队列,会在会话空闲时跑;此时报 error
|
|
814
|
+
// 是误报——实测会话正跑一个 38 分钟的回合,排队等待远超 120s 完全正常,而误报会
|
|
815
|
+
// 直接诱导调用方重发(真实事故:同一条汇报被投了两次)。所以这里只记一条 note,
|
|
816
|
+
// 继续等,由下面的总预算兜底。
|
|
773
817
|
if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
|
|
774
|
-
|
|
775
|
-
|
|
818
|
+
if (record.delivered === true) {
|
|
819
|
+
if (record.queuedNoted !== true) {
|
|
820
|
+
record.queuedNoted = true
|
|
821
|
+
pushTask(record.taskId, 'note', {
|
|
822
|
+
note: 'QUEUED',
|
|
823
|
+
text: 'prompt 已被接受并进入该会话队列,等它空闲时会跑;不要重发',
|
|
824
|
+
target: { kind: 'session', sessionId: record.sessionId },
|
|
825
|
+
delivered: true,
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
|
|
830
|
+
continue
|
|
831
|
+
}
|
|
776
832
|
}
|
|
777
833
|
if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
|
|
778
834
|
finishSessionTask(record, 'quiet-timeout')
|
|
779
835
|
continue
|
|
780
836
|
}
|
|
781
837
|
if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
|
|
782
|
-
settleSessionTask(record, 'error', {
|
|
838
|
+
settleSessionTask(record, 'error', {
|
|
839
|
+
error: record.delivered === true
|
|
840
|
+
? 'prompt 已投递进该会话队列,但预算内它始终没有开始这一轮(会话可能一直在忙,或未被激活);不要重发,可用 status 继续观察'
|
|
841
|
+
: 'session task exceeded its total budget before the prompt was accepted',
|
|
842
|
+
reason: REASONS.TIMEOUT,
|
|
843
|
+
delivered: record.delivered === true,
|
|
844
|
+
})
|
|
783
845
|
}
|
|
784
846
|
}
|
|
785
847
|
}
|
|
@@ -851,14 +913,55 @@ export async function apply(ctx) {
|
|
|
851
913
|
const parsed = await readJsonBody(req, res)
|
|
852
914
|
if (parsed === null) return
|
|
853
915
|
const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
|
|
854
|
-
|
|
916
|
+
const dryRun = parsed !== null && typeof parsed === 'object' && parsed.dryRun === true
|
|
917
|
+
if (!dryRun && instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
|
|
855
918
|
const classified = classifyTarget(parsed)
|
|
856
919
|
if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
|
|
920
|
+
// dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
|
|
921
|
+
// 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
|
|
922
|
+
// 消息去探测,结果污染了用户会话。
|
|
923
|
+
if (dryRun) {
|
|
924
|
+
if (classified.target.kind === 'session') {
|
|
925
|
+
const resolved = await resolveSessionTarget(classified.target)
|
|
926
|
+
if (!resolved.ok) {
|
|
927
|
+
sendJson(res, 200, {
|
|
928
|
+
dryRun: true,
|
|
929
|
+
resolved: false,
|
|
930
|
+
reason: resolved.reason,
|
|
931
|
+
error: resolved.message,
|
|
932
|
+
...(Array.isArray(resolved.candidates) && resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
|
|
933
|
+
})
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
sendJson(res, 200, {
|
|
937
|
+
dryRun: true,
|
|
938
|
+
resolved: true,
|
|
939
|
+
wouldDispatch: true,
|
|
940
|
+
target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
|
|
941
|
+
...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
|
|
942
|
+
...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
|
|
943
|
+
...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
|
|
944
|
+
note: '只解析目标,未投递任何消息',
|
|
945
|
+
})
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
const cwd = normalizeCwd(classified.target.cwd)
|
|
949
|
+
const lane = normalizeLane(classified.target.lane)
|
|
950
|
+
sendJson(res, 200, {
|
|
951
|
+
dryRun: true,
|
|
952
|
+
resolved: true,
|
|
953
|
+
wouldDispatch: true,
|
|
954
|
+
target: { kind: 'worker', cwd, lane },
|
|
955
|
+
note: '只解析目标,未派活',
|
|
956
|
+
})
|
|
957
|
+
return
|
|
958
|
+
}
|
|
857
959
|
const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
|
|
858
960
|
if (task.status === 'error') {
|
|
859
961
|
sendJson(res, 400, {
|
|
860
962
|
error: task.error,
|
|
861
963
|
reason: task.reason,
|
|
964
|
+
...(task.delivered === true ? { delivered: true } : {}),
|
|
862
965
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
863
966
|
})
|
|
864
967
|
return
|
|
@@ -939,7 +1042,18 @@ export async function apply(ctx) {
|
|
|
939
1042
|
}
|
|
940
1043
|
}
|
|
941
1044
|
}
|
|
942
|
-
|
|
1045
|
+
// 没命中不是死路:给近似候选(同一批只读数据,按相似度 + 最近更新排序),
|
|
1046
|
+
// 让调用方改词重查——而不是发一条消息去"探测命中"。
|
|
1047
|
+
const candidates = hits.length === 0 ? await sessionNearMisses(query, limit) : []
|
|
1048
|
+
sendJson(res, 200, {
|
|
1049
|
+
items,
|
|
1050
|
+
hasMore: hits.length > items.length,
|
|
1051
|
+
total: hits.length,
|
|
1052
|
+
matchedBy: found.matchedBy,
|
|
1053
|
+
...(candidates.length > 0
|
|
1054
|
+
? { candidates, reason: REASONS.QUERY_EMPTY, note: '标题/目录里都没有这个关键词;candidates 是按相似度与最近更新排的候选,改词重查或直接用 sessionId' }
|
|
1055
|
+
: {}),
|
|
1056
|
+
})
|
|
943
1057
|
} catch (e) {
|
|
944
1058
|
sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
|
|
945
1059
|
}
|
|
@@ -1042,6 +1156,8 @@ export async function apply(ctx) {
|
|
|
1042
1156
|
taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
|
|
1043
1157
|
target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
|
|
1044
1158
|
result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
|
|
1159
|
+
// delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
|
|
1160
|
+
...(task.delivered === undefined ? {} : { delivered: task.delivered }),
|
|
1045
1161
|
...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
|
|
1046
1162
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
1047
1163
|
...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
|
|
@@ -1143,24 +1259,48 @@ export async function apply(ctx) {
|
|
|
1143
1259
|
// ---- 原生工具(宿主全局工具层)----
|
|
1144
1260
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
1145
1261
|
name: 'dsh_collab_send',
|
|
1146
|
-
description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。'
|
|
1262
|
+
description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。'
|
|
1263
|
+
+ '【硬规则】验证"这条查询会命中哪条会话"必须用 dryRun=true(只解析、不投递);**绝不投递任何消息去试探命中**——那会作为一条真实用户消息落进对方会话并跑成回合,污染对话。'
|
|
1264
|
+
+ '【硬规则】返回里 delivered=true 表示消息已被那条会话接受(进了它的队列),即便随后观察到超时或错误也**不要重发**,用 wait=false 记下 taskId 再查状态。'
|
|
1265
|
+
+ 'sessionQuery 落空时会返回 candidates(按标题相似度 + 最近更新排序),换词重查或改用 sessionId。',
|
|
1147
1266
|
parameters: {
|
|
1148
1267
|
instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
|
|
1149
1268
|
cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
|
|
1150
1269
|
lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
|
|
1151
1270
|
model: { type: 'string', description: '模型别名: default | fast | pro。' },
|
|
1152
1271
|
sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
|
|
1153
|
-
sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(
|
|
1272
|
+
sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
|
|
1154
1273
|
deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
|
|
1274
|
+
dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
|
|
1155
1275
|
wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
|
|
1156
1276
|
},
|
|
1157
1277
|
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
|
|
1158
1278
|
async execute(args) {
|
|
1159
1279
|
const classified = classifyTarget(args)
|
|
1160
1280
|
if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
|
|
1281
|
+
// dryRun:只解析目标、回显,不投递。给调用方一条"先验证命中"的正路,免得它发消息试探。
|
|
1282
|
+
if (args.dryRun === true) {
|
|
1283
|
+
if (classified.target.kind === 'session') {
|
|
1284
|
+
const resolved = await resolveSessionTarget(classified.target)
|
|
1285
|
+
if (!resolved.ok) {
|
|
1286
|
+
return { ok: false, dryRun: true, resolved: false, reason: resolved.reason, error: resolved.message, ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}) }
|
|
1287
|
+
}
|
|
1288
|
+
return {
|
|
1289
|
+
ok: true, dryRun: true, resolved: true,
|
|
1290
|
+
target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
|
|
1291
|
+
...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
|
|
1292
|
+
...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
|
|
1293
|
+
...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
|
|
1294
|
+
note: '只解析目标,未投递任何消息',
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const cwd = normalizeCwd(classified.target.cwd)
|
|
1298
|
+
const lane = normalizeLane(classified.target.lane)
|
|
1299
|
+
return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
|
|
1300
|
+
}
|
|
1161
1301
|
const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
|
|
1162
1302
|
if (task.status === 'error') {
|
|
1163
|
-
return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
|
|
1303
|
+
return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
|
|
1164
1304
|
}
|
|
1165
1305
|
const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
|
|
1166
1306
|
if (args.wait !== true || timer === undefined) {
|
|
@@ -1171,7 +1311,7 @@ export async function apply(ctx) {
|
|
|
1171
1311
|
if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
|
|
1172
1312
|
await timer.timeout(2500)
|
|
1173
1313
|
}
|
|
1174
|
-
return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
|
|
1314
|
+
return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason, ...(task.delivered === true ? { delivered: true, resend: false } : {}) }
|
|
1175
1315
|
},
|
|
1176
1316
|
})))
|
|
1177
1317
|
|
|
@@ -232,6 +232,54 @@ export const looksLikeDisabledSearch = (error) => {
|
|
|
232
232
|
return /search is disabled/i.test(text)
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/** 去空白后的字符 bigram 集合(CJK 友好:不需要分词)。 */
|
|
236
|
+
const bigramsOf = (value) => {
|
|
237
|
+
const text = typeof value === 'string' ? value.replace(/\s+/g, '') : ''
|
|
238
|
+
const out = new Set()
|
|
239
|
+
if (text.length === 1) out.add(text)
|
|
240
|
+
for (let i = 0; i + 1 < text.length; i++) out.add(text.slice(i, i + 2))
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 查询里的 bigram 有多少比例出现在标题里(0..1)。 */
|
|
245
|
+
export const titleSimilarity = (query, title) => {
|
|
246
|
+
const q = bigramsOf(query)
|
|
247
|
+
const t = bigramsOf(title)
|
|
248
|
+
if (q.size === 0 || t.size === 0) return 0
|
|
249
|
+
let shared = 0
|
|
250
|
+
for (const gram of q) if (t.has(gram)) shared++
|
|
251
|
+
return shared / q.size
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `session-query-empty` 时给出的"近似候选"。
|
|
256
|
+
*
|
|
257
|
+
* 起因是一个真实事故:调用方搜「量化框架」没命中,又**没有别的办法确认标题**,于是
|
|
258
|
+
* 发了一条占位消息去"探测命中"——那条消息落进了用户自己的会话并跑成回合,污染了
|
|
259
|
+
* 对话。修法是不给"探测"留动机:查询落空时直接回一批候选标题(同 bigram 相似度排
|
|
260
|
+
* 序,无相似度的按最近更新补位),调用方靠只读信息就能改词重查。
|
|
261
|
+
*/
|
|
262
|
+
export const rankTitleCandidates = (query, summaries, limit = 5) => {
|
|
263
|
+
const list = Array.isArray(summaries) ? summaries : []
|
|
264
|
+
const scored = []
|
|
265
|
+
for (const summary of list) {
|
|
266
|
+
if (summary === null || typeof summary !== 'object') continue
|
|
267
|
+
if (typeof summary.sessionId !== 'string' || summary.sessionId === '') continue
|
|
268
|
+
const title = typeof summary.title === 'string' && summary.title !== '' ? summary.title : ''
|
|
269
|
+
const updatedAt = typeof summary.updatedAt === 'number' ? summary.updatedAt : 0
|
|
270
|
+
scored.push({ sessionId: summary.sessionId, snippet: title, score: titleSimilarity(query, title), updatedAt })
|
|
271
|
+
}
|
|
272
|
+
const byRecency = (a, b) => b.updatedAt - a.updatedAt
|
|
273
|
+
const related = scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score || byRecency(a, b))
|
|
274
|
+
const rest = scored.filter((s) => s.score === 0).sort(byRecency)
|
|
275
|
+
const bound = Math.max(1, Math.min(Number(limit) || 5, MAX_CANDIDATES))
|
|
276
|
+
return [...related, ...rest].slice(0, bound).map((s) => ({
|
|
277
|
+
sessionId: s.sessionId,
|
|
278
|
+
snippet: s.snippet,
|
|
279
|
+
score: Math.round(s.score * 1000) / 1000,
|
|
280
|
+
}))
|
|
281
|
+
}
|
|
282
|
+
|
|
235
283
|
/** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
|
|
236
284
|
export const taskResponse = (task) => {
|
|
237
285
|
const out = { taskId: task.taskId, status: task.status, kind: task.kind }
|
|
@@ -246,5 +294,10 @@ export const taskResponse = (task) => {
|
|
|
246
294
|
out.target = { kind: 'worker', cwd: target.cwd, lane: target.lane }
|
|
247
295
|
}
|
|
248
296
|
if (Array.isArray(task.notes) && task.notes.length > 0) out.notes = task.notes
|
|
297
|
+
// delivered=true:prompt 已被目标会话接受(进了它的队列)。调用方据此知道
|
|
298
|
+
// "别重发"——即便随后我们的观察超时并报错,消息也可能正在队列里等着跑。
|
|
299
|
+
if (task.target !== undefined && task.target !== null && task.target.kind === 'session' && task.delivered === true) {
|
|
300
|
+
out.delivered = true
|
|
301
|
+
}
|
|
249
302
|
return out
|
|
250
303
|
}
|
package/mcp/dsh-mcp.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 客户端(Secure MCP Tunnel / cloudflared)桥接给 ChatGPT 等远程产品。
|
|
7
7
|
const GATEWAY = process.env.DSH_BRIDGE_URL || 'http://127.0.0.1:3080'
|
|
8
8
|
const SERVER_NAME = 'dsh-bridge'
|
|
9
|
-
const SERVER_VERSION = '0.1.
|
|
9
|
+
const SERVER_VERSION = '0.1.7'
|
|
10
10
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
11
11
|
|
|
12
12
|
const log = (...args) => console.error('[dsh-mcp]', ...args)
|
|
@@ -20,17 +20,20 @@ const tools = [
|
|
|
20
20
|
'参数: instruction(必填,完整中文指令:目标/产出目录/验收标准/约束), cwd(工作目录,默认 D:\\Harness,通常传当前项目目录), ' +
|
|
21
21
|
'lane(并行通道名,默认 main), model(fast=deepseek-v4-flash 快而省 / pro=deepseek-v4-pro 质量优先), ' +
|
|
22
22
|
'sessionId(已有会话 id), sessionQuery(按标题/内容搜已有会话,需唯一命中,否则返回候选), deliver(queue=排队(默认) / steer=插入当前回合), ' +
|
|
23
|
-
'
|
|
23
|
+
'dryRun(true 时只解析目标并回显, 不投递任何消息), ' +
|
|
24
|
+
'wait(默认 true,阻塞至任务完成并返回 DeepSeek 汇报), timeoutSec(默认 600,最长 900)。' +
|
|
25
|
+
'注意: 验证"这条查询会命中哪条会话"必须用 dryRun 或 dsh_sessions——**绝不要投递一条消息去试探**(那会污染用户会话)。',
|
|
24
26
|
inputSchema: {
|
|
25
27
|
type: 'object',
|
|
26
28
|
properties: {
|
|
27
|
-
instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。' },
|
|
29
|
+
instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。dryRun 时可省略。' },
|
|
28
30
|
cwd: { type: 'string', description: '工作目录(绝对路径)。默认 D:\\Harness。与 sessionId/sessionQuery 互斥。' },
|
|
29
31
|
lane: { type: 'string', description: '并行通道名(默认 main)。' },
|
|
30
32
|
model: { type: 'string', enum: ['fast', 'pro'], description: '模型别名:fast=deepseek-v4-flash,pro=deepseek-v4-pro。' },
|
|
31
33
|
sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建子代理。' },
|
|
32
|
-
sessionQuery: { type: 'string', description: '
|
|
34
|
+
sessionQuery: { type: 'string', description: '按标题或内容搜索已有会话;唯一命中才派发,多条或落空会返回 candidates 让你改用 sessionId。' },
|
|
33
35
|
deliver: { type: 'string', enum: ['queue', 'steer'], description: '投递方式:queue 排队(默认),steer 插入当前回合。' },
|
|
36
|
+
dryRun: { type: 'boolean', description: '只解析目标并回显(含命中路径 matchedBy 与候选),不投递任何消息。用来确认命中,替代"发消息探测"。' },
|
|
34
37
|
wait: { type: 'boolean', description: '默认 true:阻塞直到任务完成并返回汇报。' },
|
|
35
38
|
timeoutSec: { type: 'number', description: '最长等待秒数(默认 600,最大 900)。' },
|
|
36
39
|
},
|
|
@@ -138,12 +141,19 @@ async function handleToolCall(name, args) {
|
|
|
138
141
|
sessionId: args.sessionId,
|
|
139
142
|
sessionQuery: args.sessionQuery,
|
|
140
143
|
deliver: args.deliver,
|
|
144
|
+
dryRun: args.dryRun === true,
|
|
141
145
|
})
|
|
146
|
+
// dryRun 只解析目标,不产生任务:原样回显给你看命中结果。
|
|
147
|
+
if (args.dryRun === true) return { ok: task.resolved === true, phase: 'dry-run', ...task }
|
|
142
148
|
// 4xx 拒绝(缺目标 / 会话不存在 / 搜索结果不唯一 / 被写锁占用)不带 taskId,绝不静默新建。
|
|
143
149
|
if (task.taskId === undefined) return { ok: false, phase: 'rejected', ...task }
|
|
144
150
|
if (task.status === 'error') return { ok: false, ...task }
|
|
145
151
|
if (args.wait === false) return { ok: true, phase: 'submitted', ...task }
|
|
146
152
|
const done = await waitTask(task.taskId, args.timeoutSec || 600)
|
|
153
|
+
// 会话目标报错但 delivered=true:消息已在目标会话队列里,不要重发。
|
|
154
|
+
if (done.delivered === true && done.status === 'error') {
|
|
155
|
+
return { ok: false, phase: 'queued-then-failed', resend: false, ...done }
|
|
156
|
+
}
|
|
147
157
|
return { ok: done.status === 'done', phase: 'finished', ...done }
|
|
148
158
|
}
|
|
149
159
|
if (name === 'dsh_sessions') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whaletalk/dsh-codex-collab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Codex × DeepSeek Harness 双向编码协作:Codex 主导派活与验收,DeepSeek Harness 编码子代理在共享工作目录执行并回传,独立评审子代理实际跑通构建/测试后出具报告。提供 DSH 宿主网关插件、零依赖 MCP 服务器与 Codex 技能三件套。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|