@apollo-music/dsh-sidecar-terms 0.1.4 → 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 CHANGED
@@ -7,18 +7,23 @@
7
7
  3. 默认对新增命中即时调用 `/resolve`,并只投递尚未出现的原文 `term_delta + rule_delta`;
8
8
  4. `tools/post-execute` 在下一次模型生成前 await 工具结果解析,`agent/pre-step` 与 `agent/turn-stopping` 继续作为边界 flush;
9
9
  5. request 尚未固定时 inject,step 已开始或已有候选回答时 steer;
10
- 6. steer 修订后不再审计同一 turn 的后续 assistant 输出,避免规则回执自我触发;下一 turn 自动恢复;
10
+ 6. 同一上下文周期内,steer 修订后不再审计同一 turn 的后续 assistant 输出,避免规则回执自我触发;下一 turn 自动恢复,压缩后按新周期重建;
11
11
  7. 最终答案前只 steer 尚未交付的 canonical term / Rule delta。
12
12
  8. Catalog、Resolver、鉴权或网络不可用时失败关闭,不生成离线替代规则。
13
+ 9. 压缩或裁剪上下文后,以 `session.deriveMessages()` 返回的当前可见消息重新匹配、重建 audit/inject 计数;仅补回仍有命中且提示词已不在上下文里的 term/rule。
14
+ 10. 超过 Resolver 单段上限的消息只在术语审计传输层无损分段;超过 100 段文本或 200 个候选词时分批查询,全部成功后合并投递。DSH 交给模型的原消息不变。
13
15
 
14
16
  插件固定只读 `https://apollomusic-poc1.fde0.com/kb/terminology`。`apiUrl` 不再是运行时配置项,线上服务返回 `degraded` 也会按错误处理;插件不包含本地术语表或 P0 fallback。
15
17
 
16
18
  ## 安装
17
19
 
18
- 在 DeepSeek Harness 项目中安装:
20
+ 当前版本为 `0.1.7`,对应 DeepSeek Harness `0.1.7-rc.2`。
21
+ 旧版 `0.1.5` 对应 DSH `0.1.1-rc.2`,升级插件时需同步升级宿主;`0.1.6` 是未发布的源码过渡版本。
22
+
23
+ 在对应版本的 DeepSeek Harness 项目中安装:
19
24
 
20
25
  ```bash
21
- npm install @apollo-music/dsh-sidecar-terms@0.1.4
26
+ npm install @apollo-music/dsh-sidecar-terms@0.1.7
22
27
  ```
23
28
 
24
29
  ## 配置
@@ -54,6 +59,9 @@ npm ci
54
59
  npm run build:harness-plugin
55
60
  ```
56
61
 
62
+ 发布前可在构建后运行 `npm pack ./integrations/deepseek-harness --ignore-scripts`,
63
+ 将生成的 `.tgz` 安装到 DSH 测试项目;这不会发布 npm。
64
+
57
65
  凭据示例:
58
66
 
59
67
  ```bash
@@ -74,7 +82,30 @@ export APOLLO_TERMINOLOGY_READ_API_KEY='<READ_ONLY_API_KEY>'
74
82
 
75
83
  `auditReasoning: true` 只匹配每个 step 已提交的完整 reasoning block;流式 `reasoning-delta` 不参与匹配。thinking 原文只发送给 Resolver 做瞬时匹配,不会回注给模型。若部署边界不允许 thinking 离开 Harness 进程,可设为 `false`。
76
84
 
77
- 该插件声明依赖 `agents`、`sessions` 与 `tools`;Cordis 会等相关服务存在后再加载。官方 Harness 目前仍处于 Developer Preview,本项目锁定并测试 `0.1.1-rc.2` 的公开 `Agent`、`session/event`、`tools/post-execute`、`agent/pre-step`、`agent/turn-stopping` 接口。包清单显式声明这一组接口的完整 DSH peer 闭包,避免依赖宿主包管理器自动补齐隐式 peer。
85
+ 该插件声明依赖 `agents`、`sessions` 与 `tools`;Cordis 会等相关服务存在后再加载。本项目锁定并测试 DSH `0.1.7-rc.2` 的公开 `Agent`、`session/event`、`agent/assistant-stream`、`tools/post-execute`、`agent/pre-step`、`agent/request`、`agent/turn-stopping` 接口。包清单显式声明这一组接口的完整 DSH peer 闭包,避免依赖宿主包管理器自动补齐隐式 peer。
86
+
87
+ `0.1.6` 使用独立的消息来源 `plugin:apollo-terminology-rules`,与上游将旧插件消息迁移到 v4 会话格式后的来源一致。工具结果现在由 `role: tool` 消息承载;若另一插件替换 canonical `value`,本插件等待 DSH 渲染并提交 `tool/result` 后在 pre-step 审计,不匹配已被替换的旧内容。pre-step 增量保留上游 `startsRequestSeries` 等准入字段。
88
+
89
+ ## 压缩后的重新计数(0.1.7)
90
+
91
+ 这里的“重新计数”指重新建立命中、已投递集合和 audit/inject 指针,不是重新计算模型 token 用量,也不改变 DSH 的压缩策略。
92
+
93
+ - 摘要、未压缩的近期消息和本轮即将准入的用户消息参与重新匹配;已被替换的原始日志不参与。启动/恢复会话也使用同一可见上下文入口。
94
+ - 自身完整保留的 term/rule 提示词继续算已投递;被压掉且仍有命中的提示词在下一次模型请求前补回。被压掉且不再命中的内容不会复活,自身提示词也不反向触发新命中。
95
+ - 新提示词的来源元数据记录 term/rule ID 和正文 SHA-256;正文被裁剪或修改后不沿用旧回执。摘要里提到某条规则不等于完整规则仍在。保留的 0.1.6 原文提示词按已有来源和固定格式读取 ID。
96
+ - 每次替换开启独立 `contextEpoch`,双指针使用该轮上下文内的递增序号;不把重排后的原日志 seq 当作增量水位。压缩前未完成的查询结果作废,队列里过期的本插件增量移除,其他输入不动。
97
+ - 普通压缩在 `agent/pre-step` 重建;上下文溢出后的同一步重试由 `agent/request` 兜底,在请求构建前提交增量,不改写 provider 请求。已生成候选答案时,最终门禁仍只 steer 剩余增量。
98
+ - 重建必须完成后才能继续生成,不受 `queryIntervalMs` 或普通 `flushOnPreStep: false` 的延迟设置影响;查询失败则阻止该次生成。默认开启的最终门禁维持原有语义。
99
+
100
+ 实现不依赖具体 compaction 插件,而是监听公开的会话 surface replacement。完整设计、一手上游依据和验证记录见仓库 [`docs/DSH_CONTEXT_RECOUNT.md`](https://github.com/fde0-inc/apollo-terminology-knowledge-base/blob/main/docs/DSH_CONTEXT_RECOUNT.md)。
101
+
102
+ ## 验证
103
+
104
+ 无凭据的真实 DSH 运行时回归已纳入 `npm test`(模型和 API 均为测试数据,不联网):
105
+
106
+ ```bash
107
+ npx tsx --test tests/harness-runtime.test.ts
108
+ ```
78
109
 
79
110
  配置线上只读凭据后验证:
80
111
 
@@ -1,6 +1,13 @@
1
1
  import { type UserMessage } from "@deepseek-ai/dsh-llm";
2
2
  import { TerminologyApi } from "./api.js";
3
3
  import type { AgentPort, LoggerPort, PluginConfig, ResolveResponse } from "./types.js";
4
+ /**
5
+ * Split one audited message for the resolver transport without changing the
6
+ * message that DSH sends to the model. Chunks preserve order and concatenate
7
+ * exactly to the original text; natural or serialized-JSON boundaries are
8
+ * preferred so a multi-word terminology match is not cut unnecessarily.
9
+ */
10
+ export declare function splitResolverText(text: string): string[];
4
11
  interface AuditItem {
5
12
  eventSeq: number;
6
13
  source: string;
@@ -14,8 +21,14 @@ export declare class TerminologyCoordinator {
14
21
  constructor(config: PluginConfig, api: TerminologyApi, logger: LoggerPort);
15
22
  attach(agent: AgentPort): void;
16
23
  disposeAgent(agent: AgentPort): void;
24
+ /** Retire all asynchronous work belonging to the now-shadowed context. */
25
+ invalidateContext(agent: AgentPort): void;
26
+ /** Start a fresh accounting epoch, crediting only intact visible receipts. */
27
+ resetContext(agent: AgentPort, retained: readonly UserMessage[]): void;
17
28
  dispose(): void;
18
29
  observe(agent: AgentPort, item: AuditItem): void;
30
+ /** Runtime accounting uses an epoch-local sequence, not reordered log seqs. */
31
+ observeNext(agent: AgentPort, item: Omit<AuditItem, "eventSeq">): void;
19
32
  setStepPhase(agent: AgentPort, activeStep: boolean, generationStarted?: boolean): void;
20
33
  beginTurn(agent: AgentPort): void;
21
34
  shouldAuditAssistant(agent: AgentPort): boolean;
@@ -26,6 +39,7 @@ export declare class TerminologyCoordinator {
26
39
  flush(agent: AgentPort, forceSteer?: boolean): Promise<void>;
27
40
  flushForPreStep(agent: AgentPort): Promise<UserMessage[]>;
28
41
  pointers(agent: AgentPort): {
42
+ contextEpoch: number;
29
43
  auditCursor: number;
30
44
  injectCursor: number;
31
45
  deliveredRuleIds: string[];
@@ -1,6 +1,67 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
3
  import { findCatalogMatches } from "./matcher.js";
3
- const PLUGIN_NAME = "apollo-terminology-rules";
4
+ // This also matches the source produced by DSH's v3-to-v4 session migration
5
+ // for our previously published { kind: "plugin", plugin: "apollo-terminology-rules" }.
6
+ const MESSAGE_SOURCE_KIND = "plugin:apollo-terminology-rules";
7
+ const RESOLVER_TEXT_TARGET_CHARS = 90_000;
8
+ const RESOLVER_MAX_SEGMENTS = 100;
9
+ const RESOLVER_MAX_CANDIDATES = 200;
10
+ const NATURAL_TEXT_BOUNDARIES = [
11
+ { needle: "\n\n", offset: 2 },
12
+ { needle: "\n", offset: 1 },
13
+ { needle: "},{\"", offset: 2 },
14
+ { needle: "\",\"", offset: 2 },
15
+ { needle: "。", offset: 1 },
16
+ { needle: "!", offset: 1 },
17
+ { needle: "?", offset: 1 },
18
+ { needle: ". ", offset: 2 },
19
+ { needle: "! ", offset: 2 },
20
+ { needle: "? ", offset: 2 },
21
+ { needle: "; ", offset: 2 },
22
+ { needle: ";", offset: 1 },
23
+ { needle: " ", offset: 1 },
24
+ ];
25
+ /**
26
+ * Split one audited message for the resolver transport without changing the
27
+ * message that DSH sends to the model. Chunks preserve order and concatenate
28
+ * exactly to the original text; natural or serialized-JSON boundaries are
29
+ * preferred so a multi-word terminology match is not cut unnecessarily.
30
+ */
31
+ export function splitResolverText(text) {
32
+ if (text.length <= RESOLVER_TEXT_TARGET_CHARS)
33
+ return [text];
34
+ const chunks = [];
35
+ let start = 0;
36
+ while (start < text.length) {
37
+ const targetEnd = Math.min(start + RESOLVER_TEXT_TARGET_CHARS, text.length);
38
+ if (targetEnd === text.length) {
39
+ chunks.push(text.slice(start));
40
+ break;
41
+ }
42
+ const preferredStart = start + Math.floor(RESOLVER_TEXT_TARGET_CHARS * 0.75);
43
+ let end = 0;
44
+ for (const boundary of NATURAL_TEXT_BOUNDARIES) {
45
+ const index = text.lastIndexOf(boundary.needle, targetEnd - boundary.needle.length);
46
+ const candidate = index + boundary.offset;
47
+ if (index >= preferredStart && candidate > end)
48
+ end = candidate;
49
+ }
50
+ if (end <= start)
51
+ end = targetEnd;
52
+ // JavaScript string limits are measured in UTF-16 code units. Avoid
53
+ // placing a transport boundary between the halves of a surrogate pair.
54
+ const before = text.charCodeAt(end - 1);
55
+ const after = text.charCodeAt(end);
56
+ if (before >= 0xD800 && before <= 0xDBFF
57
+ && after >= 0xDC00 && after <= 0xDFFF) {
58
+ end -= 1;
59
+ }
60
+ chunks.push(text.slice(start, end));
61
+ start = end;
62
+ }
63
+ return chunks;
64
+ }
4
65
  export class TerminologyCoordinator {
5
66
  config;
6
67
  api;
@@ -16,13 +77,35 @@ export class TerminologyCoordinator {
16
77
  this.states.set(agent, createState());
17
78
  }
18
79
  disposeAgent(agent) {
80
+ this.invalidateContext(agent);
81
+ this.states.delete(agent);
82
+ }
83
+ /** Retire all asynchronous work belonging to the now-shadowed context. */
84
+ invalidateContext(agent) {
19
85
  const state = this.states.get(agent);
20
86
  if (!state)
21
87
  return;
22
88
  state.disposed = true;
23
89
  if (state.queryTimer)
24
90
  clearTimeout(state.queryTimer);
25
- this.states.delete(agent);
91
+ }
92
+ /** Start a fresh accounting epoch, crediting only intact visible receipts. */
93
+ resetContext(agent, retained) {
94
+ const epoch = (this.states.get(agent)?.contextEpoch ?? -1) + 1;
95
+ this.invalidateContext(agent);
96
+ const state = createState(epoch);
97
+ for (const message of retained) {
98
+ const receipt = readVisibleReceipt(message);
99
+ if (!receipt)
100
+ continue;
101
+ for (const id of receipt.ruleIds)
102
+ state.deliveredRuleIds.add(id);
103
+ for (const id of receipt.termIds)
104
+ state.deliveredTermIds.add(id);
105
+ if (receipt.metadata)
106
+ state.announcedMetadata = receipt.metadata;
107
+ }
108
+ this.states.set(agent, state);
26
109
  }
27
110
  dispose() {
28
111
  for (const agent of this.states.keys())
@@ -32,11 +115,17 @@ export class TerminologyCoordinator {
32
115
  const state = this.requireState(agent);
33
116
  if (state.disposed || item.eventSeq <= state.auditCursor)
34
117
  return;
118
+ state.nextAuditSeq = Math.max(state.nextAuditSeq, item.eventSeq + 1);
35
119
  state.auditQueue.push(item);
36
120
  state.processing = state.processing.then(() => this.drainAudit(agent, state)).catch((error) => {
37
121
  this.logger.warn("terminology audit deferred: %s", errorMessage(error));
38
122
  });
39
123
  }
124
+ /** Runtime accounting uses an epoch-local sequence, not reordered log seqs. */
125
+ observeNext(agent, item) {
126
+ const state = this.requireState(agent);
127
+ this.observe(agent, { ...item, eventSeq: state.nextAuditSeq });
128
+ }
40
129
  setStepPhase(agent, activeStep, generationStarted = false) {
41
130
  const state = this.requireState(agent);
42
131
  state.activeStep = activeStep;
@@ -62,6 +151,8 @@ export class TerminologyCoordinator {
62
151
  }
63
152
  confirmOwnMessage(agent, message) {
64
153
  const state = this.requireState(agent);
154
+ if (state.disposed)
155
+ return;
65
156
  const delivery = state.deliveries.get(String(message.id));
66
157
  if (!delivery)
67
158
  return;
@@ -97,6 +188,7 @@ export class TerminologyCoordinator {
97
188
  pointers(agent) {
98
189
  const state = this.requireState(agent);
99
190
  return {
191
+ contextEpoch: state.contextEpoch,
100
192
  auditCursor: state.auditCursor,
101
193
  injectCursor: state.injectCursor,
102
194
  deliveredRuleIds: [...state.deliveredRuleIds].sort(),
@@ -109,6 +201,8 @@ export class TerminologyCoordinator {
109
201
  if (state.auditQueue.length === 0 || state.disposed)
110
202
  return;
111
203
  const catalog = await this.api.getCatalog();
204
+ if (state.disposed)
205
+ return;
112
206
  while (state.auditQueue.length) {
113
207
  const item = state.auditQueue.shift();
114
208
  if (item.eventSeq <= state.auditCursor)
@@ -165,18 +259,52 @@ export class TerminologyCoordinator {
165
259
  async queryOnce(agent, state, mode, captured) {
166
260
  const segments = [...state.pendingSegments.values()].sort((a, b) => a.event_seq - b.event_seq);
167
261
  const throughSeq = segments.at(-1).event_seq;
262
+ const resolverSegments = segments.flatMap(({ event_seq, source, text }) => splitResolverText(text).map((chunk) => ({ event_seq, source, text: chunk })));
263
+ const candidates = [...new Set(segments.flatMap((segment) => segment.candidates ?? []))];
168
264
  for (const segment of segments)
169
265
  state.pendingSegments.delete(segment.event_seq);
170
266
  state.lastQueryAt = Date.now();
171
267
  try {
172
- const response = await this.api.resolve({
173
- session_id: agent.id,
174
- audit_through_seq: throughSeq,
175
- segments: segments.map(({ event_seq, source, text }) => ({ event_seq, source, text })),
176
- candidate_terms: [...new Set(segments.flatMap((segment) => segment.candidates ?? []))],
177
- delivered_rule_ids: this.knownRuleIds(state),
178
- delivered_term_ids: this.knownTermIds(state),
179
- });
268
+ // A whole-context recount can exceed either transport array limit. Hold
269
+ // every batch until all succeed; a failed/stale batch cannot partly
270
+ // advance delivery accounting or leave a half-rebuilt prompt behind.
271
+ const ruleIds = new Set(this.knownRuleIds(state));
272
+ const termIds = new Set(this.knownTermIds(state));
273
+ const rules = new Map();
274
+ const terms = new Map();
275
+ let response;
276
+ const batchCount = Math.max(Math.ceil(resolverSegments.length / RESOLVER_MAX_SEGMENTS), Math.ceil(candidates.length / RESOLVER_MAX_CANDIDATES));
277
+ for (let batch = 0; batch < batchCount; batch += 1) {
278
+ const batchSegments = resolverSegments.slice(batch * RESOLVER_MAX_SEGMENTS, (batch + 1) * RESOLVER_MAX_SEGMENTS);
279
+ const partial = await this.api.resolve({
280
+ session_id: agent.id,
281
+ audit_through_seq: throughSeq,
282
+ // Extra candidate batches still require one non-empty segment.
283
+ segments: batchSegments.length ? batchSegments : resolverSegments.slice(0, 1),
284
+ candidate_terms: candidates.slice(batch * RESOLVER_MAX_CANDIDATES, (batch + 1) * RESOLVER_MAX_CANDIDATES),
285
+ delivered_rule_ids: [...ruleIds].sort(),
286
+ delivered_term_ids: [...termIds].sort(),
287
+ });
288
+ if (state.disposed)
289
+ return;
290
+ for (const rule of partial.rule_delta) {
291
+ rules.set(rule.rule_id, rule);
292
+ ruleIds.add(rule.rule_id);
293
+ }
294
+ for (const term of partial.term_delta ?? []) {
295
+ terms.set(term.term_id, term);
296
+ termIds.add(term.term_id);
297
+ }
298
+ response = {
299
+ ...partial,
300
+ hits: [...response?.hits ?? [], ...partial.hits],
301
+ unresolved: [...response?.unresolved ?? [], ...partial.unresolved],
302
+ rule_delta: [...rules.values()],
303
+ term_delta: [...terms.values()],
304
+ };
305
+ }
306
+ if (!response)
307
+ return;
180
308
  const checkpoint = {
181
309
  throughSeq,
182
310
  complete: response.rule_delta.length === 0 && (response.term_delta?.length ?? 0) === 0,
@@ -193,6 +321,8 @@ export class TerminologyCoordinator {
193
321
  this.logger.debug("terminology resolved through seq %d: %d hit(s), %d new term(s), %d new rule(s)", throughSeq, response.hits.length, response.term_delta?.length ?? 0, response.rule_delta.length);
194
322
  }
195
323
  catch (error) {
324
+ if (state.disposed)
325
+ return;
196
326
  for (const segment of segments)
197
327
  state.pendingSegments.set(segment.event_seq, segment);
198
328
  throw error;
@@ -200,9 +330,20 @@ export class TerminologyCoordinator {
200
330
  }
201
331
  dispatch(agent, state, response, checkpoint, mode) {
202
332
  const metadataUpdate = metadataDelta(state.announcedMetadata, response);
333
+ const text = renderDelta(response, metadataUpdate);
203
334
  const message = createUserMessage({
204
- content: [{ type: "text", text: renderDelta(response, metadataUpdate) }],
205
- source: { kind: "plugin", plugin: PLUGIN_NAME, form: "instructions" },
335
+ content: [{ type: "text", text }],
336
+ source: {
337
+ kind: MESSAGE_SOURCE_KIND,
338
+ form: "instructions",
339
+ receipt: {
340
+ version: 1,
341
+ ruleIds: response.rule_delta.map((rule) => rule.rule_id),
342
+ termIds: (response.term_delta ?? []).map((term) => term.term_id),
343
+ ...metadataSnapshot(response),
344
+ textSha256: textDigest(text),
345
+ },
346
+ },
206
347
  });
207
348
  const effectiveMode = mode === "auto"
208
349
  ? (state.activeStep || state.generationStarted ? "steer" : "inject")
@@ -220,6 +361,8 @@ export class TerminologyCoordinator {
220
361
  return message;
221
362
  }
222
363
  promoteUnclaimedInjections(agent, state) {
364
+ if (state.disposed)
365
+ return;
223
366
  const pendingIds = new Set(agent.inbox.nextStep.map((message) => String(message.id)));
224
367
  for (const delivery of state.deliveries.values()) {
225
368
  if (delivery.mode === "steer" || delivery.mode === "capture")
@@ -264,10 +407,40 @@ export class TerminologyCoordinator {
264
407
  }
265
408
  }
266
409
  export function isOwnPluginMessage(message) {
267
- return message.source.kind === "plugin" && message.source.plugin === PLUGIN_NAME;
410
+ return message.source.kind === MESSAGE_SOURCE_KIND;
411
+ }
412
+ function readVisibleReceipt(message) {
413
+ if (message.source.kind !== MESSAGE_SOURCE_KIND)
414
+ return;
415
+ if (message.content.length !== 1 || message.content[0].type !== "text")
416
+ return;
417
+ const text = message.content[0].text;
418
+ const receipt = message.source.receipt;
419
+ if (receipt !== undefined) {
420
+ if (receipt.version !== 1 || receipt.textSha256 !== textDigest(text))
421
+ return;
422
+ return {
423
+ ruleIds: receipt.ruleIds,
424
+ termIds: receipt.termIds,
425
+ metadata: { kbVersion: receipt.kbVersion, catalogRevision: receipt.catalogRevision },
426
+ };
427
+ }
428
+ // 0.1.6 receipts had only our producer kind and the canonical text framing.
429
+ // Never infer delivered IDs from a summary, user text, or another producer.
430
+ if (!text.startsWith("[阿波罗术语库:原文增量]\n"))
431
+ return;
432
+ return {
433
+ ruleIds: [...text.matchAll(/^- \[P[01]\] (RULE-[A-Z0-9-]+): /gmu)].map((match) => match[1]),
434
+ termIds: [...text.matchAll(/^- \[术语 (TERM-[A-Z0-9-]+) \/ (?:locked|contextual|pending|deprecated)\] /gmu)].map((match) => match[1]),
435
+ };
436
+ }
437
+ function textDigest(text) {
438
+ return createHash("sha256").update(text).digest("hex");
268
439
  }
269
- function createState() {
440
+ function createState(contextEpoch = 0) {
270
441
  return {
442
+ contextEpoch,
443
+ nextAuditSeq: 0,
271
444
  auditCursor: -1,
272
445
  injectCursor: -1,
273
446
  auditQueue: [],
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare const inject: string[];
7
7
  export type Config = PluginConfig;
8
8
  export declare const Config: Schema<Config>;
9
9
  export declare function apply(ctx: Context, config: Config): void;
10
- export declare function messageText(message: Message, auditReasoning?: boolean): string;
10
+ export declare function messageText(message: Message, auditReasoning?: boolean, auditToolArguments?: boolean): string;
11
11
  export { APOLLO_TERMINOLOGY_API_URL, TerminologyApi } from "./api.js";
12
12
  export { TerminologyCoordinator } from "./coordinator.js";
13
13
  export { findCatalogMatches, normalizeMatchKey } from "./matcher.js";
package/dist/index.js CHANGED
@@ -23,11 +23,18 @@ export function apply(ctx, config) {
23
23
  const agentsBySession = new Map();
24
24
  const attach = (agent) => {
25
25
  coordinator.attach(agent);
26
- agentsBySession.set(agent.session, agent);
27
- for (const event of agent.session.events)
28
- observeEvent(agent, event);
26
+ // Resume from the model-visible projection, never from shadowed raw events.
27
+ agentsBySession.set(agent.session, { agent, needsRecount: true, seenMessages: new Set(), offered: [] });
29
28
  };
30
29
  const observeEvent = (agent, event) => {
30
+ const binding = agentsBySession.get(agent.session);
31
+ if ("surfaceOp" in event && event.surfaceOp !== "append" && event.type !== "system/message") {
32
+ // Summary compaction and tool pruning both commit through this seam.
33
+ // Invalidate synchronously so a late old-context response cannot steer.
34
+ binding.needsRecount = true;
35
+ coordinator.invalidateContext(agent);
36
+ return;
37
+ }
31
38
  switch (event.type) {
32
39
  case "turn/start":
33
40
  coordinator.beginTurn(agent);
@@ -41,35 +48,28 @@ export function apply(ctx, config) {
41
48
  case "step/end":
42
49
  coordinator.endStep(agent);
43
50
  return;
44
- case "assistant/chunk": {
45
- const chunk = event.data.chunk;
46
- if (chunk.type === "text-delta" || chunk.type === "reasoning-delta" || chunk.type === "tool-call-delta") {
47
- coordinator.markGenerationStarted(agent);
48
- }
49
- return;
50
- }
51
51
  case "user/message": {
52
52
  const message = event.data;
53
+ binding.offered = binding.offered.filter((offered) => offered.id !== message.id);
53
54
  if (isOwnPluginMessage(message)) {
54
55
  coordinator.confirmOwnMessage(agent, message);
55
56
  return;
56
57
  }
57
- enqueueMessage(agent, event.seq, "user", message);
58
+ enqueueMessage(agent, "user", message);
58
59
  return;
59
60
  }
60
61
  case "assistant/message":
61
62
  coordinator.markGenerationStarted(agent);
62
63
  if (coordinator.shouldAuditAssistant(agent)) {
63
- enqueueMessage(agent, event.seq, "assistant", event.data.message);
64
+ enqueueMessage(agent, "assistant", event.data.message);
64
65
  }
65
66
  return;
66
67
  case "tool/result":
67
- enqueueMessage(agent, event.seq, "tool_result", event.data.message);
68
+ enqueueMessage(agent, "tool_result", event.data.message);
68
69
  return;
69
70
  case "tool/call":
70
- if (config.auditToolArguments) {
71
- coordinator.observe(agent, {
72
- eventSeq: event.seq,
71
+ if (config.auditToolArguments && !binding.needsRecount) {
72
+ coordinator.observeNext(agent, {
73
73
  source: "tool_call",
74
74
  text: `${event.data.name}\n${event.data.arguments}`,
75
75
  });
@@ -79,32 +79,94 @@ export function apply(ctx, config) {
79
79
  return;
80
80
  }
81
81
  };
82
- const enqueueMessage = (agent, eventSeq, source, message) => {
83
- const text = messageText(message, config.auditReasoning);
82
+ const enqueueMessage = (agent, source, message, auditArguments = false) => {
83
+ const binding = agentsBySession.get(agent.session);
84
+ if (binding.needsRecount || binding.seenMessages.has(String(message.id)))
85
+ return;
86
+ binding.seenMessages.add(String(message.id));
87
+ const text = messageText(message, config.auditReasoning, auditArguments);
84
88
  if (text)
85
- coordinator.observe(agent, { eventSeq, source, text });
89
+ coordinator.observeNext(agent, { source, text });
90
+ };
91
+ const recount = async (agent, offered, signal, capture = true) => {
92
+ const binding = agentsBySession.get(agent.session);
93
+ let messages = offered;
94
+ let additions = [];
95
+ let rebuilt = false;
96
+ try {
97
+ while (binding.needsRecount) {
98
+ signal.throwIfAborted();
99
+ const retained = agent.session.deriveMessages();
100
+ const retainedIds = new Set(retained.map((message) => message.id));
101
+ messages = messages.filter((message) => !isOwnPluginMessage(message) && !retainedIds.has(message.id));
102
+ coordinator.resetContext(agent, retained.filter((message) => message.role === "user" && isOwnPluginMessage(message)));
103
+ binding.seenMessages.clear();
104
+ binding.needsRecount = false;
105
+ // The offered batch has already left the inbox; remove only remaining
106
+ // sidecar messages there. Recompute their relevance from retained input.
107
+ for (const pending of [...agent.inbox.nextStep]) {
108
+ if (isOwnPluginMessage(pending))
109
+ agent.inbox.remove(pending.id);
110
+ }
111
+ for (const message of [...retained, ...messages]) {
112
+ if (message.role === "user" && isOwnPluginMessage(message))
113
+ continue;
114
+ enqueueMessage(agent, `context_${message.role}`, message, config.auditToolArguments);
115
+ }
116
+ if (capture)
117
+ additions = await coordinator.flushForPreStep(agent);
118
+ else
119
+ await coordinator.flush(agent, true);
120
+ signal.throwIfAborted();
121
+ rebuilt = true;
122
+ // Another replacement while querying requires another fresh snapshot.
123
+ }
124
+ }
125
+ catch (error) {
126
+ binding.needsRecount = true;
127
+ coordinator.invalidateContext(agent);
128
+ throw error;
129
+ }
130
+ return { messages, additions, rebuilt };
86
131
  };
87
132
  for (const agent of ctx.agents.list())
88
133
  attach(agent);
89
- ctx.on("agent/created", ({ agent }) => attach(agent));
134
+ ctx.on("agent/created", ({ agent }) => {
135
+ attach(agent);
136
+ return undefined;
137
+ });
90
138
  ctx.on("agent/disposed", ({ agent }) => {
91
139
  agentsBySession.delete(agent.session);
92
140
  coordinator.disposeAgent(agent);
93
141
  });
94
142
  ctx.on("agent/inbox/discarded", ({ agent, message }) => coordinator.markDiscarded(agent, message));
143
+ ctx.on("agent/assistant-stream", ({ agent, frame }) => {
144
+ if (frame.type !== "chunk")
145
+ return;
146
+ const chunk = frame.chunk;
147
+ if (chunk.type === "text-delta" || chunk.type === "reasoning-delta" || chunk.type === "tool-call-delta") {
148
+ coordinator.markGenerationStarted(agent);
149
+ }
150
+ });
95
151
  ctx.on("tools/post-execute", async (exec, result, next) => {
96
152
  const decision = await next();
97
153
  const agent = exec.agent;
98
154
  if (!agent)
99
155
  return decision;
156
+ if (agentsBySession.get(agent.session)?.needsRecount)
157
+ return decision;
158
+ // A canonical value replacement is rendered by the registry after this
159
+ // waterfall. Audit its committed tool/result at the next pre-step instead
160
+ // of matching stale content or invoking the tool's renderer a second time.
161
+ if (decision.kind === "accept" && "value" in decision)
162
+ return decision;
100
163
  const content = decision.kind === "block"
101
164
  ? decision.feedback
102
165
  : decision.content ?? result.content;
103
166
  const text = content.flatMap((block) => blockText(block, config.auditReasoning)).filter(Boolean).join("\n").trim();
104
167
  if (!text)
105
168
  return decision;
106
- coordinator.observe(agent, {
107
- eventSeq: Math.max(agent.session.seq, coordinator.pointers(agent).auditCursor + 1),
169
+ coordinator.observeNext(agent, {
108
170
  source: "tool_result",
109
171
  text,
110
172
  });
@@ -117,30 +179,43 @@ export function apply(ctx, config) {
117
179
  };
118
180
  });
119
181
  ctx.on("session/event", (session, event) => {
120
- const agent = agentsBySession.get(session);
121
- if (agent)
122
- observeEvent(agent, event);
182
+ const binding = agentsBySession.get(session);
183
+ if (binding)
184
+ observeEvent(binding.agent, event);
123
185
  });
124
186
  ctx.on("agent/pre-step", async (payload, next) => {
125
187
  const decision = await next();
126
188
  if (decision.kind === "reject")
127
189
  return decision;
128
190
  coordinator.setStepPhase(payload.agent, false, false);
129
- const baseSeq = payload.agent.session.seq;
130
- decision.messages.forEach((message, index) => {
191
+ const prepared = await recount(payload.agent, decision.messages, payload.signal);
192
+ const binding = agentsBySession.get(payload.agent.session);
193
+ binding.offered = prepared.messages;
194
+ prepared.messages.forEach((message) => {
131
195
  if (!isOwnPluginMessage(message))
132
- enqueueMessage(payload.agent, baseSeq + index, "pre_step", message);
196
+ enqueueMessage(payload.agent, "pre_step", message);
133
197
  });
134
- if (!config.flushOnPreStep)
135
- return decision;
136
- const additions = await coordinator.flushForPreStep(payload.agent);
137
- return additions.length
138
- ? { kind: "enter", messages: [...decision.messages, ...additions] }
139
- : decision;
198
+ const additions = prepared.rebuilt
199
+ ? prepared.additions
200
+ : config.flushOnPreStep ? await coordinator.flushForPreStep(payload.agent) : [];
201
+ return { ...decision, messages: [...prepared.messages, ...additions] };
202
+ });
203
+ ctx.on("agent/request", async ({ agent, signal }, next) => {
204
+ const route = await next();
205
+ // Context-overflow recovery retries within the same step, bypassing
206
+ // pre-step. Commit the rebuilt delta before the driver derives that request.
207
+ const binding = agentsBySession.get(agent.session);
208
+ const prepared = await recount(agent, binding.offered, signal);
209
+ for (const message of prepared.additions) {
210
+ signal.throwIfAborted();
211
+ agent.session.append("user/message", message, { surfaceOp: "append" });
212
+ }
213
+ return route;
140
214
  });
141
- ctx.on("agent/turn-stopping", async ({ agent }) => {
215
+ ctx.on("agent/turn-stopping", async ({ agent, signal }) => {
142
216
  if (!config.flushOnTurnStopping)
143
217
  return;
218
+ await recount(agent, [], signal, false);
144
219
  await coordinator.flush(agent, true);
145
220
  });
146
221
  ctx.effect(() => () => coordinator.dispose());
@@ -150,8 +225,10 @@ export function apply(ctx, config) {
150
225
  logger.warn("initial terminology catalog fetch failed; live events will retry: %s", error instanceof Error ? error.message : String(error));
151
226
  });
152
227
  }
153
- export function messageText(message, auditReasoning = true) {
154
- return message.content.flatMap((block) => blockText(block, auditReasoning)).filter(Boolean).join("\n").trim();
228
+ export function messageText(message, auditReasoning = true, auditToolArguments = false) {
229
+ return message.content.flatMap((block) => block.type === "tool-call" && auditToolArguments
230
+ ? [`${block.name}\n${block.arguments}`]
231
+ : blockText(block, auditReasoning)).filter(Boolean).join("\n").trim();
155
232
  }
156
233
  function blockText(block, auditReasoning) {
157
234
  if (block.type === "text")
@@ -160,8 +237,6 @@ function blockText(block, auditReasoning) {
160
237
  // Streaming reasoning-delta chunks are lifecycle signals and never enter here.
161
238
  if (block.type === "reasoning")
162
239
  return auditReasoning ? [block.text] : [];
163
- if (block.type === "tool-result")
164
- return block.content.flatMap((item) => blockText(item, auditReasoning));
165
240
  return [];
166
241
  }
167
242
  export { APOLLO_TERMINOLOGY_API_URL, TerminologyApi } from "./api.js";
package/dist/types.d.ts CHANGED
@@ -1,4 +1,21 @@
1
1
  import type { UserMessage } from "@deepseek-ai/dsh-llm";
2
+ export interface DeliveryReceipt {
3
+ version: 1;
4
+ ruleIds: string[];
5
+ termIds: string[];
6
+ kbVersion: string;
7
+ catalogRevision: number;
8
+ textSha256: string;
9
+ }
10
+ declare module "@deepseek-ai/dsh-llm" {
11
+ interface MessageSourceMap {
12
+ "plugin:apollo-terminology-rules": {
13
+ kind: "plugin:apollo-terminology-rules";
14
+ form: "instructions";
15
+ receipt?: DeliveryReceipt;
16
+ };
17
+ }
18
+ }
2
19
  export interface PluginConfig {
3
20
  apiKeyEnv: string;
4
21
  queryIntervalMs: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollo-music/dsh-sidecar-terms",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "DeepSeek Harness sidecar plugin for incremental Apollo terminology and rule injection",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,20 +40,27 @@
40
40
  "prepack": "npm run build"
41
41
  },
42
42
  "peerDependencies": {
43
- "@deepseek-ai/cordis": "^4.0.1",
44
- "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
45
- "@deepseek-ai/dsh-attachment": "0.1.1-rc.2",
46
- "@deepseek-ai/dsh-brand": "0.1.1-rc.2",
47
- "@deepseek-ai/dsh-code-runtime": "0.1.1-rc.2",
48
- "@deepseek-ai/dsh-invariants": "0.1.1-rc.2",
49
- "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
50
- "@deepseek-ai/dsh-scope": "0.1.1-rc.2",
51
- "@deepseek-ai/dsh-session": "0.1.1-rc.2",
52
- "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
53
- "@deepseek-ai/dsh-timeout": "0.1.1-rc.2",
54
- "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
55
- "@deepseek-ai/dsh-typert-protocol": "0.1.1-rc.2",
56
- "@deepseek-ai/dsh-user-approval": "0.1.1-rc.2",
57
- "@deepseek-ai/schemastery": "^3.18.1"
43
+ "@deepseek-ai/cordis": "~4.0.4",
44
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
45
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
46
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
47
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
48
+ "@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.2",
49
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
50
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
51
+ "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
53
+ "@deepseek-ai/dsh-session-persistence": "0.1.7-rc.2",
54
+ "@deepseek-ai/dsh-session-projection": "0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-storage": "0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-storage-domain": "0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
58
+ "@deepseek-ai/dsh-timeout": "0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
63
+ "@deepseek-ai/dsh-workspace": "0.1.7-rc.2",
64
+ "@deepseek-ai/schemastery": "~3.18.4"
58
65
  }
59
66
  }