dsh-compact-relay 0.3.2 → 0.3.4

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 (43) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.en.md +8 -4
  3. package/README.md +8 -4
  4. package/ROADMAP.md +30 -3
  5. package/TUTORIAL.md +3 -2
  6. package/lib/actions/better-compact.js +24 -72
  7. package/lib/actions/compact-run.js +76 -0
  8. package/lib/actions/compress-hint.js +16 -7
  9. package/lib/actions/handoff-docs.js +9 -3
  10. package/lib/actions/handoff.js +5 -22
  11. package/lib/actions/settings.js +1 -4
  12. package/lib/actions/state.js +2 -1
  13. package/lib/actions/support.js +76 -0
  14. package/lib/actions/test-compact.js +12 -41
  15. package/lib/adapters/http.js +2 -1
  16. package/lib/adapters/prompt.js +72 -0
  17. package/lib/adapters/tool.js +12 -8
  18. package/lib/client.js +424 -96
  19. package/lib/config.js +1 -2
  20. package/lib/core/compress-cue.js +96 -0
  21. package/lib/core/compress-hint.js +1 -1
  22. package/lib/core/errors.js +0 -1
  23. package/lib/core/handoff-spec.js +1 -1
  24. package/lib/core/markdown-lite.js +178 -0
  25. package/lib/core/object.js +19 -0
  26. package/lib/core/ranges.js +1 -2
  27. package/lib/core/relay.js +2 -2
  28. package/lib/core/tokens.js +34 -0
  29. package/lib/dsh/compaction-log.js +3 -19
  30. package/lib/dsh/compaction-pair.js +48 -0
  31. package/lib/dsh/engine.js +2 -2
  32. package/lib/dsh/handoff-files.js +28 -0
  33. package/lib/dsh/handoff-writer.js +16 -21
  34. package/lib/dsh/model-task.js +112 -0
  35. package/lib/dsh/relay.js +1 -1
  36. package/lib/dsh/session-view.js +5 -17
  37. package/lib/dsh/settings-file.js +1 -4
  38. package/lib/dsh/state-hub.js +95 -19
  39. package/lib/dsh/summarizer.js +16 -25
  40. package/lib/index.js +28 -6
  41. package/package.json +2 -2
  42. package/lib/actions/stage.js +0 -36
  43. package/lib/core/stage.js +0 -96
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 动作层胶水 —— 多个动作共用的「运行时细节」,不含业务判断。
3
+ *
4
+ * 以前这些片段在 actions/better-compact.js 与 actions/handoff.js 里各写一遍
5
+ * (test-compact 也有一份信号组合),而且取消语义必须一致:
6
+ * 用户取消 ↔ 网络错误 ↔ 已提交/落盘状态不明的错误,文案与错误码不能各说各话。
7
+ *
8
+ * 放 actions 层(不是 core/dsh):这里要碰 deps.inflight(动作登记表)和
9
+ * core/errors 的错误包装,是「动作怎么跑」的公共约定,不是纯函数能力。
10
+ */
11
+ import { actionError } from '../core/errors.js';
12
+
13
+ /**
14
+ * 组合两个 AbortSignal(任一为 undefined 时直接返回另一个)。
15
+ *
16
+ * 用途:用户取消信号(输入)与事务 idle 信号(runMaintenance 给的)合并;
17
+ * 交接里是「inflight 取消信号」与「命令信号」合并。
18
+ * @param {AbortSignal|undefined} a
19
+ * @param {AbortSignal|undefined} b
20
+ * @returns {AbortSignal|undefined}
21
+ */
22
+ export function combinedSignal(a, b) {
23
+ if (a === undefined) return b;
24
+ if (b === undefined) return a;
25
+ return AbortSignal.any([a, b]);
26
+ }
27
+
28
+ /**
29
+ * 登记「进行中的动作」,拿 AbortController(取消按钮真正能 abort 模型调用)。
30
+ * deps.inflight 是可选的(测试/旧装配没有就不登记)。
31
+ * @param {{inflight?:object}} deps
32
+ * @param {string} agentId
33
+ * @param {'compact'|'handoff'} kind
34
+ * @returns {AbortController|null}
35
+ */
36
+ export function beginInflight(deps, agentId, kind) {
37
+ return deps.inflight !== undefined && typeof deps.inflight.begin === 'function'
38
+ ? deps.inflight.begin(agentId, kind)
39
+ : null;
40
+ }
41
+
42
+ /**
43
+ * 注销进行中的动作(必须放 finally,任何失败/取消都不能把登记漏在表里)。
44
+ * @param {{inflight?:object}} deps
45
+ * @param {string} agentId
46
+ * @param {AbortController|null} controller
47
+ */
48
+ export function finishInflight(deps, agentId, controller) {
49
+ if (controller !== null && typeof deps.inflight.finish === 'function') {
50
+ deps.inflight.finish(agentId, controller);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 取消收口:信号已中止、但错误不是「取消/已提交/落盘不明」时,统一包装成
56
+ * cancelled(宿主 HTTP 层据此回「历史没改」)。
57
+ *
58
+ * P2-1:commit/persistence 表示历史可能已改或落盘状态不明,绝不能谎报「历史没改」;
59
+ * 这类错误原样抛出。返回错误给调用方 throw(保持各自 try/catch 结构不变)。
60
+ *
61
+ * @param {unknown} error 原始错误
62
+ * @param {AbortSignal|undefined} signal 合并后的信号
63
+ * @param {string} message 该动作的取消文案(用户可见,调用方给)
64
+ * @returns {unknown} 要抛出的错误
65
+ */
66
+ export function cancelErrorOf(error, signal, message) {
67
+ if (
68
+ signal?.aborted === true &&
69
+ error?.code !== 'cancelled' &&
70
+ error?.code !== 'commit' &&
71
+ error?.code !== 'persistence'
72
+ ) {
73
+ return actionError('cancelled', message, error);
74
+ }
75
+ return error;
76
+ }
@@ -7,20 +7,12 @@
7
7
  import { actionError } from '../core/errors.js';
8
8
  import { TEST_INSTRUCTION } from '../core/prompts.js';
9
9
  import { selectTestRange } from '../core/ranges.js';
10
- import { replaceRangeWithSummary } from '../dsh/compact-transaction.js';
11
- import { runIdle } from '../dsh/idle.js';
10
+ import { sumShadowedTokens } from '../core/tokens.js';
12
11
  import { measurePressure } from '../dsh/pressure.js';
13
12
  import { firstCompactableIdx } from '../dsh/session-view.js';
14
- import { summarize } from '../dsh/summarizer.js';
13
+ import { runCompaction } from './compact-run.js';
15
14
  import { withLock } from './lock.js';
16
15
 
17
- function tokenCountOf(nodes, shadowedSeqs) {
18
- const wanted = new Set(shadowedSeqs);
19
- return nodes
20
- .filter((node) => wanted.has(node.seq))
21
- .reduce((sum, node) => sum + (node.heuristicTokens ?? node.tokens ?? 0), 0);
22
- }
23
-
24
16
  /**
25
17
  * @param {{ctx:object, cfg:object, lock:object, engine:object}} deps
26
18
  * @param {{agent:object, signal?:AbortSignal, confirm?:boolean, commandId?:string}} input
@@ -44,7 +36,7 @@ export async function testCompact(deps, { agent, signal, confirm = false, comman
44
36
  throw actionError('range', '找不到配对平衡的小范围(会话太短,或工具有未配对调用)');
45
37
  }
46
38
 
47
- const shadowedTokenCount = tokenCountOf(nodes, range.shadowedSeqs);
39
+ const shadowedTokenCount = sumShadowedTokens(nodes, range.shadowedSeqs);
48
40
  const freshRange = { ...range, shadowedTokenCount };
49
41
 
50
42
  if (!confirm) {
@@ -58,36 +50,15 @@ export async function testCompact(deps, { agent, signal, confirm = false, comman
58
50
  };
59
51
  }
60
52
 
61
- const flush =
62
- typeof deps.ctx.sessions?.flush === 'function' ? () => deps.ctx.sessions.flush(session) : undefined;
63
- const estimateMessage =
64
- typeof deps.ctx?.tokenMeter?.estimateMessage === 'function'
65
- ? (message) => deps.ctx.tokenMeter.estimateMessage(message)
66
- : undefined;
67
-
68
- const outcome = await runIdle(agent, (idleSignal) => {
69
- const combined = signal === undefined ? idleSignal : AbortSignal.any([signal, idleSignal]);
70
- return replaceRangeWithSummary({
71
- session,
72
- range: freshRange,
73
- engine: deps.engine,
74
- signal: combined,
75
- sourceCommandId: commandId,
76
- estimateMessage,
77
- summarize: ({ shadowedSeqs, signal: innerSignal }) =>
78
- summarize({
79
- ctx: deps.ctx,
80
- engine: deps.engine,
81
- agent,
82
- shadowedSeqs,
83
- endSeq: freshRange.endSeq,
84
- instruction: TEST_INSTRUCTION,
85
- requiredSections: [], // /bctest 是短段落摘要,不做六段校验
86
- maxTokens: deps.cfg.summary.maxTokens,
87
- signal: innerSignal,
88
- }),
89
- flush,
90
- });
53
+ // 和正式精压走同一条执行路径,只是范围小、摘要指令不同、不做六段校验。
54
+ const outcome = await runCompaction(deps, {
55
+ agent,
56
+ session,
57
+ range: freshRange,
58
+ instruction: TEST_INSTRUCTION,
59
+ requiredSections: [], // /bctest 是短段落摘要,不做六段校验
60
+ signal,
61
+ commandId,
91
62
  });
92
63
 
93
64
  return {
@@ -205,7 +205,8 @@ export function registerHttp(webCtx, deps) {
205
205
  }
206
206
  const limit = Number(url.searchParams.get('limit') ?? 20);
207
207
  const listed = listHandoffDocs(deps, { limit });
208
- return send(res, 200, { ok: true, dir: listed.dir, items: listed.items });
208
+ // 0.3.4(BUG-006):带 total(全量份数),客户端据此显示「共 N 份」/加载更多。
209
+ return send(res, 200, { ok: true, dir: listed.dir, items: listed.items, total: listed.total });
209
210
  } catch (error) {
210
211
  try {
211
212
  send(res, 500, { ok: false, message: errorText(error) });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 模型入口(0.3.4)——「当前在可压缩区间」的动态提示。
3
+ *
4
+ * BUG-010:0.3.1 的 `mark_stage` 工具在 427 个真实会话里 0 次调用。根因不是模型
5
+ * 不听话,而是工具描述承诺「系统会告诉你容量」但系统从没说过 —— 条件不可判断。
6
+ * 现在宿主每轮把一条 runtime context 注入 system prompt,告诉模型当前容量落在
7
+ * 可压缩区间;模型据此判断「刚完成一个自然节点」时该不该调 `mark_compress_point`。
8
+ *
9
+ * 约束:
10
+ * - `systemPrompt.context` 的 text 回调必须是**同步**函数 → 只读
11
+ * `dsh/state-hub.js` 维护的内存缓存,不 await、不 measure;
12
+ * - 拿不到会话/缓存/阈值就返回空串(宿主不会展示空 context);
13
+ * - 全程 try/catch:提示是锦上添花,任何异常都不能影响宿主组装 prompt。
14
+ */
15
+ import {
16
+ COMPRESS_CUE_NAME,
17
+ COMPRESS_CUE_ORDER,
18
+ compressCueText,
19
+ } from '../core/compress-cue.js';
20
+ import { compressionMinRatio } from '../core/policy.js';
21
+
22
+ /** 供测试/文档引用的 context 名称。 */
23
+ export { COMPRESS_CUE_NAME };
24
+
25
+ /**
26
+ * 计算某个 agent 本轮该显示的动态提示(同步、异常安全)。
27
+ * @param {{context?:object, deps?:object, hub?:object}} input
28
+ * context 是宿主传给 systemPrompt.context text 回调的对象(context.agent 是当前 agent)
29
+ * @returns {string} 提示文本;不适用/异常返回 ''
30
+ */
31
+ export function compressCueFor({ context, deps, hub } = {}) {
32
+ try {
33
+ // 只认会话 id(agent.id 与 session.id 不同,缓存也按 sessionId 键控)。
34
+ const sessionId = context?.agent?.session?.id;
35
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return '';
36
+
37
+ const cached = hub?.latestState?.(sessionId);
38
+ if (cached === undefined || cached === null) return '';
39
+
40
+ const thresholds = deps?.cfg?.thresholds;
41
+ if (thresholds === null || typeof thresholds !== 'object') return '';
42
+ if (!Number.isFinite(thresholds.compactRatio)) return '';
43
+ if (!Number.isFinite(cached.ratio) || !Number.isFinite(cached.window) || cached.window <= 0) return '';
44
+
45
+ // 最小线是「配置值 vs 动态下限(保留尾部 + 最小可压 token)/窗口」的较大者(纯函数)。
46
+ const minRatio = compressionMinRatio(thresholds, deps?.cfg?.retention, cached.window);
47
+ return compressCueText({
48
+ ratio: cached.ratio,
49
+ minRatio,
50
+ compactRatio: thresholds.compactRatio,
51
+ compressHint: cached.compressHint ?? null,
52
+ });
53
+ } catch {
54
+ return '';
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 注册动态提示(root-scope,所有 agent 生效)。
60
+ * @param {object} ctx 根上下文(需要 systemPrompt 服务)
61
+ * @param {object} deps
62
+ * @param {object} hub createStateHub 的返回值(要 latestState)
63
+ * @returns {Function|undefined} disposer(没有 systemPrompt 服务时返回 undefined)
64
+ */
65
+ export function registerCompressCue(ctx, deps, hub) {
66
+ if (typeof ctx?.systemPrompt?.context !== 'function') return undefined;
67
+ return ctx.systemPrompt.context({
68
+ name: COMPRESS_CUE_NAME,
69
+ order: COMPRESS_CUE_ORDER,
70
+ text: (context) => compressCueFor({ context, deps, hub }),
71
+ });
72
+ }
@@ -1,8 +1,13 @@
1
1
  /**
2
- * 工具入口(0.3.1)—— 让模型判断「这里适合压缩」并记录一个断点。
2
+ * 工具入口(0.3.1,0.3.4 改名 mark_compress_point)—— 让模型记录一个「压缩节点」。
3
3
  *
4
4
  * 用户手动标记已删除(作者 2026-10-20 拍板);现在只有模型这条自动判断路径,
5
5
  * 环上把判断位置画成「断点1」(正常区/压缩区分界)。
6
+ *
7
+ * BUG-010 复盘:旧描述 `mark_stage` 写「系统会告诉你容量」但系统从没说过,
8
+ * 模型无法判断条件 → 427 个真实会话 0 次调用。0.3.4 起由宿主每轮注入可压缩区间
9
+ * 提示(lib/adapters/prompt.js),工具描述只描述「它自己会做什么」:
10
+ * 服务端工具会返回当前容量并复核区间,太低/到线都拒绝且不改历史。
6
11
  */
7
12
  import { markCompressHint } from '../actions/compress-hint.js';
8
13
  import engine from '../dsh/engine.js';
@@ -14,14 +19,13 @@ import engine from '../dsh/engine.js';
14
19
  export function registerTool(ctx, deps) {
15
20
  ctx.tools.register(
16
21
  engine.defineTool({
17
- name: 'mark_stage',
22
+ name: 'mark_compress_point',
18
23
  description:
19
- '判断「这里适合压缩」并在指示灯上留一个断点(正常区/压缩区的分界)。' +
20
- '只在同时满足以下条件时调用:① 当前上下文容量已经超过「最小压缩线」(低于它压缩不划算,系统会告诉你);' +
21
- '② 还没到「压缩线」(到了系统自己会标记);③ 当前工作刚好到了一个自然的小节点——一个明确的任务/阶段刚完成,' +
22
- '适合把之前的旧历史压成摘要、继续往下做。' +
23
- '不要每个小步骤都调;不要为了凑数调用。每次判断只记一条,压缩或交接后旧记录自动失效。' +
24
- '调用后告诉用户「我在当前进度处做了一个「这里适合压缩」的判断」。',
24
+ '记录一个「压缩节点」:在指示灯环上标出「这里适合把前面的旧历史压成摘要」。' +
25
+ '在你刚完成一个明确的任务/阶段、并且认为之后可以安全地压缩旧历史继续往下做时调用。' +
26
+ '不确定当前是否合适时也可以调用:工具会返回当前上下文容量并做判断,' +
27
+ '容量太低(压缩不划算)或已经到压缩线(直接建议用户压缩即可)会被拒绝,拒绝不会改动任何历史。' +
28
+ '不需要每个小步骤都调用。每次判断只记一条,压缩或交接后旧记录自动失效。',
25
29
  parameters: {},
26
30
  output: {
27
31
  schema: { type: 'string' },