@sema-agent/server 7.76.1 → 7.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/USAGE.md +47 -21
  2. package/dist/boot/engine-lease.d.ts +89 -11
  3. package/dist/boot/engine-lease.js +26 -11
  4. package/dist/boot/exit-last.d.ts +24 -0
  5. package/dist/boot/exit-last.js +46 -0
  6. package/dist/boot/shutdown.js +28 -4
  7. package/dist/config-catalog.js +3 -2
  8. package/dist/config-types.d.ts +23 -11
  9. package/dist/config.js +8 -8
  10. package/dist/fleet/subagent-tail-bus.d.ts +28 -2
  11. package/dist/fleet/subagent-tail-bus.js +24 -2
  12. package/dist/http/principal-gate.d.ts +43 -1
  13. package/dist/http/principal-gate.js +23 -1
  14. package/dist/http/routes/a2a-serve.js +2 -2
  15. package/dist/http/routes/admin-config-refresh.js +2 -2
  16. package/dist/http/routes/admin-drain.js +2 -2
  17. package/dist/http/routes/admin-memory-optout.js +2 -2
  18. package/dist/http/routes/adoption.js +2 -2
  19. package/dist/http/routes/approvals-assistant.js +4 -4
  20. package/dist/http/routes/attachments.js +2 -2
  21. package/dist/http/routes/config-catalog.js +2 -2
  22. package/dist/http/routes/devices.js +2 -2
  23. package/dist/http/routes/fleet.js +2 -2
  24. package/dist/http/routes/leader.js +2 -2
  25. package/dist/http/routes/memory-bundle.js +2 -2
  26. package/dist/http/routes/memory-compliance.js +2 -2
  27. package/dist/http/routes/memory-consolidation.js +2 -2
  28. package/dist/http/routes/memory-origin.js +2 -2
  29. package/dist/http/routes/memory-policy.js +2 -2
  30. package/dist/http/routes/notify-wake.js +3 -3
  31. package/dist/http/routes/observability.js +2 -2
  32. package/dist/http/routes/retention-ops.js +2 -2
  33. package/dist/http/routes/run-memory-capture.js +2 -2
  34. package/dist/http/routes/runs.js +13 -13
  35. package/dist/http/routes/sessions.js +53 -12
  36. package/dist/http/routes/side-query.js +2 -2
  37. package/dist/http/routes/tasks.js +14 -10
  38. package/dist/http/routes/workflows.js +3 -3
  39. package/dist/http/server.js +10 -23
  40. package/dist/observability/fail-open.d.ts +4 -0
  41. package/dist/observability/fail-open.js +4 -0
  42. package/dist/parent-watch.d.ts +32 -18
  43. package/dist/parent-watch.js +12 -10
  44. package/dist/parked-decide.js +1 -1
  45. package/dist/plugins/file-run-store.d.ts +6 -1
  46. package/dist/plugins/file-run-store.js +10 -0
  47. package/dist/plugins/memory-run-store.d.ts +6 -1
  48. package/dist/plugins/memory-run-store.js +10 -0
  49. package/dist/plugins/run-store-sql.d.ts +13 -2
  50. package/dist/plugins/run-store-sql.js +5 -0
  51. package/dist/plugins/store-contracts.d.ts +16 -0
  52. package/dist/runs.js +4 -10
  53. package/dist/tool-approval.js +2 -2
  54. package/dist/trace/ledger-sink.js +3 -14
  55. package/dist/trace/model-segment.d.ts +55 -0
  56. package/dist/trace/model-segment.js +58 -0
  57. package/dist/trace/project.d.ts +17 -0
  58. package/dist/trace/project.js +9 -1
  59. package/dist/trace/redact.d.ts +23 -1
  60. package/dist/trace/redact.js +49 -15
  61. package/package.json +1 -1
@@ -208,7 +208,7 @@ export async function decideParkedAgent(deps, req, match) {
208
208
  return "retry";
209
209
  }
210
210
  };
211
- defaultSubagentTailBus.forgetHandleContentMode(match.handle);
211
+ defaultSubagentTailBus.forgetHandle(match.handle);
212
212
  let receipt;
213
213
  try {
214
214
  receipt = await deps.reviveTool.execute(mintReviveArgs(row, deps.knownAgentTypes ?? new Set()), ctx);
@@ -1,6 +1,6 @@
1
1
  import { type UsageRow } from "../usage-analytics.js";
2
2
  import type { TaskStatus } from "@sema-agent/core";
3
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
3
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
4
4
  import type { RunStoreCheckpointProbe } from "./memory-run-store.js";
5
5
  import type { LedgerEventType } from "../trace/ledger-events.js";
6
6
  /**
@@ -127,6 +127,11 @@ export declare class FileRunStore {
127
127
  };
128
128
  limit: number;
129
129
  }): Promise<RunRecord[]>;
130
+ /**
131
+ * S-297 —— 这个会话**最近一条腿**的窄指针({@link SqlRunStore.latestRunForSession} 的孪生:同一个排序口径
132
+ * `createdAt DESC, taskId DESC`,与本店 {@link FileRunStore.listSessions} 挑 `rn=1` 那一行的比较器逐字同一份)。
133
+ */
134
+ latestRunForSession(sessionId: string): Promise<LatestRunRef | undefined>;
130
135
  /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
131
136
  listSessions(opts: {
132
137
  owner?: string;
@@ -443,6 +443,16 @@ export class FileRunStore {
443
443
  .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
444
444
  return rows.slice(0, opts.limit).map((r) => this.toRecord(r));
445
445
  }
446
+ async latestRunForSession(sessionId) {
447
+ let latest;
448
+ for (const r of this.runs.values()) {
449
+ if (r.sessionId !== sessionId)
450
+ continue;
451
+ if (latest === undefined || r.createdAt.getTime() > latest.createdAt.getTime() || (r.createdAt.getTime() === latest.createdAt.getTime() && r.taskId > latest.taskId))
452
+ latest = r;
453
+ }
454
+ return latest === undefined ? undefined : { taskId: latest.taskId, owner: latest.owner, createdAt: latest.createdAt.toISOString() };
455
+ }
446
456
  async listSessions(opts) {
447
457
  const bySession = new Map();
448
458
  for (const r of this.runs.values()) {
@@ -1,6 +1,6 @@
1
1
  import { type UsageRow } from "../usage-analytics.js";
2
2
  import type { TaskStatus } from "@sema-agent/core";
3
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
3
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
4
4
  import type { LedgerEventType } from "../trace/ledger-events.js";
5
5
  /**
6
6
  * [ref] P0 — the local lane's stand-in for the SQL twins' checkpoint-table JOIN. The file/memory run stores
@@ -76,6 +76,11 @@ export declare class MemoryRunStore {
76
76
  };
77
77
  limit: number;
78
78
  }): Promise<RunRecord[]>;
79
+ /**
80
+ * S-297 —— 这个会话**最近一条腿**的窄指针({@link SqlRunStore.latestRunForSession} 的孪生:同一个排序口径
81
+ * `createdAt DESC, taskId DESC`,与本店 {@link MemoryRunStore.listSessions} 挑 `rn=1` 那一行的比较器逐字同一份)。
82
+ */
83
+ latestRunForSession(sessionId: string): Promise<LatestRunRef | undefined>;
79
84
  /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
80
85
  listSessions(opts: {
81
86
  owner?: string;
@@ -190,6 +190,16 @@ export class MemoryRunStore {
190
190
  .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
191
191
  return rows.slice(0, opts.limit).map((r) => this.toRecord(r));
192
192
  }
193
+ async latestRunForSession(sessionId) {
194
+ let latest;
195
+ for (const r of this.runs.values()) {
196
+ if (r.sessionId !== sessionId)
197
+ continue;
198
+ if (latest === undefined || r.createdAt.getTime() > latest.createdAt.getTime() || (r.createdAt.getTime() === latest.createdAt.getTime() && r.taskId > latest.taskId))
199
+ latest = r;
200
+ }
201
+ return latest === undefined ? undefined : { taskId: latest.taskId, owner: latest.owner, createdAt: latest.createdAt.toISOString() };
202
+ }
193
203
  async listSessions(opts) {
194
204
  const bySession = new Map();
195
205
  for (const r of this.runs.values()) {
@@ -2,10 +2,10 @@ import type { TaskStatus } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool } from "pg";
4
4
  import { type UsageRow } from "../usage-analytics.js";
5
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
5
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
6
6
  import { type SqlDriver } from "./sql-driver.js";
7
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
8
- export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
8
+ export type { RunRecord, SessionSummary, RunEvent, LatestRunRef } from "./store-contracts.js";
9
9
  export declare class SqlRunStore {
10
10
  protected readonly db: SqlDriver;
11
11
  constructor(db: SqlDriver);
@@ -149,6 +149,17 @@ export declare class SqlRunStore {
149
149
  };
150
150
  limit: number;
151
151
  }): Promise<RunRecord[]>;
152
+ /**
153
+ * S-297 —— 这个会话**最近一条腿**的窄指针(`/mcp` 面板的 `lastLegMcp` 与任何「上一条 run 是谁」的读面共用)。
154
+ *
155
+ * 排序口径与 {@link listSessions} 的 `rn=1` 窗函数**逐字同一句**(`created_at DESC, task_id DESC`)——
156
+ * 两处答的是同一个问题「哪一条是最新的」,口径分叉会让列表面的 `lastRunId` 与本读口指向不同的 run。
157
+ * 走的也是同一条索引路径(`idx_task_run_session_status` 的 `session_id` 前缀),所以**不新加索引**:
158
+ * 会话内行数有界,前缀定位之后的排序只在这一小撮行上。
159
+ *
160
+ * 两方言只差占位符(动态子句为零,不像 listRuns/listSessions 那样需要各自的构造器臂)。
161
+ */
162
+ latestRunForSession(sessionId: string): Promise<LatestRunRef | undefined>;
152
163
  /** GET /v1/sessions (CC /resume picker): DISTINCT sessions newest-first by last activity, keyset-paginated on
153
164
  * (last_activity, session_id). Aggregates task_run by session_id — one window pass picks the latest run per
154
165
  * session (rn=1) for the preview/status and rolls up first/last/count. Owner-scoped when set (the per-user view).
@@ -252,6 +252,11 @@ export class SqlRunStore {
252
252
  const { rows } = await this.db.query(sql, params);
253
253
  return rows.map((r) => this.rowToRun(r));
254
254
  }
255
+ async latestRunForSession(sessionId) {
256
+ const { rows } = await this.db.query(this.q("SELECT task_id, owner, created_at FROM task_run WHERE session_id = ? ORDER BY created_at DESC, task_id DESC LIMIT 1", "SELECT task_id, owner, created_at FROM task_run WHERE session_id = $1 ORDER BY created_at DESC, task_id DESC LIMIT 1"), [sessionId]);
257
+ const r = rows[0];
258
+ return r === undefined ? undefined : { taskId: String(r.task_id), owner: r.owner ?? null, createdAt: iso(r.created_at) };
259
+ }
255
260
  async listSessions(opts) {
256
261
  if (this.db.dialect === "tidb") {
257
262
  const params = [];
@@ -244,6 +244,22 @@ export interface SessionSummary {
244
244
  * titled OR the lister face has no session_meta (the legacy task_run aggregate / memory twins — honest degrade). */
245
245
  title: string | null;
246
246
  }
247
+ /**
248
+ * S-297 —— 「这个会话**最近一条腿**是哪一条 run」的窄读面(`latestRunForSession` 的回值)。
249
+ *
250
+ * 刻意**不是** {@link RunRecord}:那一形携 `result` 整只 blob,而本读面的三个消费点(run 身份 / 属主门 /
251
+ * 排序口径的自证)一个都用不到它 —— 与 `listSessions` 只提标量 `lastTaskRunId` 而不拉 `result` 是同一条
252
+ * 判据([ref]:列表/指针面只拉标量,不把 blob 拖进按会话的高频读)。
253
+ *
254
+ * `owner` = 该 run 的**提交 principal**(可为 null = 无属主的存量行),消费点据它判「这条腿的读面该不该
255
+ * 投给当前调用方」;它与会话的 canonical owner 会分叉,判据与先例逐字见 `run-store-sql.ts deleteBySession`
256
+ * 的头注(F-A)。`createdAt` = 排序键(ISO),三孪生同一口径 `created_at DESC, task_id DESC`。
257
+ */
258
+ export interface LatestRunRef {
259
+ taskId: string;
260
+ owner: string | null;
261
+ createdAt: string;
262
+ }
247
263
  export interface RunEvent {
248
264
  seq: number;
249
265
  type: string;
package/dist/runs.js CHANGED
@@ -10,7 +10,7 @@ import { recordTurnActivity } from "./turn-activity.js";
10
10
  import { CANCELLED_CODE, mintCancelledResult } from "./run-cancel-context.js";
11
11
  import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
12
12
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
13
- import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
13
+ import { defaultSubagentTailBus } from "./fleet/subagent-tail-bus.js";
14
14
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
15
15
  import { recordFailOpen } from "./observability/fail-open.js";
16
16
  import { errorText as errText } from "./observability/err-text.js";
@@ -275,19 +275,13 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
275
275
  fleetPublisher?.onForwardEvent(e);
276
276
  sink.onForwardEvent(e);
277
277
  const bg = e.bgAgentId;
278
- if (bg !== undefined) {
279
- defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
280
- if (defaultSubagentTailBus.hasSubscribers(bg)) {
281
- const f = projectTailFrame(e);
282
- if (f)
283
- defaultSubagentTailBus.publish(bg, f);
284
- }
285
- }
278
+ if (bg !== undefined)
279
+ defaultSubagentTailBus.onForwardFrame(bg, tailContentMode, e);
286
280
  },
287
281
  onTaskNotification: (n, opts) => {
288
282
  if (n.task_type === "workflow")
289
283
  return;
290
- defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
284
+ defaultSubagentTailBus.forgetHandle(n.task_id);
291
285
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
292
286
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
293
287
  }
@@ -2,7 +2,7 @@ import { contentKindOf } from "./approval-content-kind.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { uuidv7, MAX_RULE_TEXT_CHARS, parseRuleText, pathConfinableWriteToolNames } from "@sema-agent/core";
4
4
  import { precheckCardRuleText } from "./rules-consent.js";
5
- import { normalizeForRedaction, redactDeep, redactSecrets } from "./trace/redact.js";
5
+ import { redactDeep, redactSecrets, redactSecretsWithArms } from "./trace/redact.js";
6
6
  import { hasBidiControls } from "./text-bidi.js";
7
7
  import { approvalPayloadFingerprint } from "./plugins/approval-payload-fingerprint.js";
8
8
  import { createLogger } from "./observability/logger.js";
@@ -32,7 +32,7 @@ const DEFAULT_PARK_TOMBSTONE_TTL_MS = 60 * 60_000;
32
32
  const DEFAULT_PARK_TOMBSTONE_PER_PRINCIPAL_MAX = 128;
33
33
  const PARKING_RETRY_AFTER_MS = 250;
34
34
  function ruleMaterialCarriesCredentialShape(command) {
35
- return redactSecrets(command, { observe: false }) !== normalizeForRedaction(command);
35
+ return redactSecretsWithArms(command, { observe: false }).arms.has("full");
36
36
  }
37
37
  function durableRuleMaterial(material) {
38
38
  if (material === undefined)
@@ -2,6 +2,7 @@ import { terminalProjection } from "@sema-agent/core";
2
2
  import { modelTextEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, toolRosterDeltaEventData, humanInputEventData, turnEndEventData, contextUsageEventData } from "./project.js";
3
3
  import { NotifiedKeys, taskNotificationStreamKey } from "../orchestration/workflow-completion-inbox.js";
4
4
  import { isParkLedgerEventType } from "./ledger-events.js";
5
+ import { isModelSegmentBoundary } from "./model-segment.js";
5
6
  export function createSerialLedgerAppend(appendEvent, startSeq) {
6
7
  let seq = startSeq;
7
8
  let chain = Promise.resolve();
@@ -43,6 +44,8 @@ export function createLedgerSink(opts) {
43
44
  };
44
45
  const onEvent = async (ev) => {
45
46
  beat(ev.type);
47
+ if (isModelSegmentBoundary(ev.type))
48
+ await flush();
46
49
  switch (ev.type) {
47
50
  case "text_delta": {
48
51
  const td = ev;
@@ -55,15 +58,12 @@ export function createLedgerSink(opts) {
55
58
  reasoning += ev.delta;
56
59
  break;
57
60
  case "tool_start":
58
- await flush();
59
61
  await append("tool_start", toolStartEventData(ev));
60
62
  break;
61
63
  case "tool_end":
62
- await flush();
63
64
  await append("tool_end", toolEndEventData(ev));
64
65
  break;
65
66
  case "status":
66
- await flush();
67
67
  {
68
68
  const statusData = brainStatusEventData(ev);
69
69
  beat("status", statusData);
@@ -71,49 +71,39 @@ export function createLedgerSink(opts) {
71
71
  }
72
72
  break;
73
73
  case "context_usage":
74
- await flush();
75
74
  await append("context_usage", contextUsageEventData(ev));
76
75
  break;
77
76
  case "compacted":
78
- await flush();
79
77
  await append("compacted", compactedEventData(ev));
80
78
  break;
81
79
  case "diagnostics":
82
- await flush();
83
80
  await append("diagnostics", diagnosticsEventData(ev));
84
81
  break;
85
82
  case "steering_injected":
86
- await flush();
87
83
  await append("steering_injected", steeringInjectedEventData(ev));
88
84
  break;
89
85
  case "compaction_outcome":
90
- await flush();
91
86
  await append("compaction_outcome", compactionOutcomeEventData(ev));
92
87
  break;
93
88
  case "workspace_changed":
94
- await flush();
95
89
  await append("workspace_changed", workspaceChangedEventData(ev));
96
90
  break;
97
91
  case "wiring_manifest":
98
- await flush();
99
92
  await append("wiring_manifest", wiringManifestEventData(ev));
100
93
  break;
101
94
  case "tool_roster_delta": {
102
- await flush();
103
95
  const rosterDelta = toolRosterDeltaEventData(ev);
104
96
  if (rosterDelta !== undefined)
105
97
  await append("tool_roster_delta", rosterDelta);
106
98
  break;
107
99
  }
108
100
  case "human_input":
109
- await flush();
110
101
  await append("human_input", humanInputEventData(ev));
111
102
  break;
112
103
  case "task_progress":
113
104
  await append("task_progress", taskProgressEventData(ev));
114
105
  break;
115
106
  case "task_notification": {
116
- await flush();
117
107
  const tn = ev.notification;
118
108
  const tnKey = tn?.task_id ? taskNotificationStreamKey({ task_type: tn.task_type, task_id: tn.task_id, status: String(tn.status), seq: tn.seq }) : undefined;
119
109
  const tnPrior = tnKey !== undefined ? keys.get(tnKey) : undefined;
@@ -126,7 +116,6 @@ export function createLedgerSink(opts) {
126
116
  break;
127
117
  }
128
118
  case "turn_end": {
129
- await flush();
130
119
  const usage = ev.usage;
131
120
  await append("turn_end", turnEndEventData(ev));
132
121
  textEventId = undefined;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 本帧是不是模型文本的段界。**四条消费腿共用这一只**:账本投影 switch(`trace/ledger-sink.ts`)、
3
+ * 顶层 live 流与转发子代腿(`http/routes/tasks.ts`)、子代 tail 面(`fleet/subagent-tail-bus.ts`)。
4
+ * 未知/未来词一律 `false` —— 不是兜底,是上面三道钉保证「未知词不存在」之后剩下的诚实答案
5
+ * (第三方 Runner 塞进来的野帧不该切走宿主的权威段)。
6
+ */
7
+ export declare function isModelSegmentBoundary(type: string): boolean;
8
+ /** live 面的段末帧(`type` 即事件名;其余键由 `reasoningEndEventData` 白名单铸出)。 */
9
+ export type ReasoningEndFrame = Record<string, unknown> & {
10
+ type: "reasoning_end";
11
+ };
12
+ /** 登记册**读到的全部字段**,逐个写出来(不是 `Record<string, unknown>`):`TaskEvent` 与转发腿的原始帧
13
+ * 都结构性满足它,于是调用点一个宽断言都不必写;将来有人想读第七个键,得先在这里加一行。 */
14
+ export interface ReasoningStreamEvent {
15
+ type?: string;
16
+ delta?: unknown;
17
+ eventId?: unknown;
18
+ parentToolCallId?: unknown;
19
+ sourceTaskId?: unknown;
20
+ bgAgentId?: unknown;
21
+ }
22
+ /** 一条**流**的身份(不含 `eventId`:core 给每个内容事件铸各自的 eventId,拿它当流身份会把每块 delta
23
+ * 切成一段)。三键都缺席 = 宿主自己的流。 */
24
+ export interface ReasoningStreamIdent {
25
+ parentToolCallId?: string;
26
+ sourceTaskId?: string;
27
+ bgAgentId?: string;
28
+ }
29
+ /**
30
+ * 一条 live 腿上**按流身份分账**的 `reasoning` 段登记册。
31
+ *
32
+ * 🔴 **为什么是登记册而不是一只累积器**(codex 对抗复审 R1 两根 [high],亲跑复现后按类修):一条腿上
33
+ * 并发子代的 `reasoning_delta` 是**交错**到达的。一只累积器只有两种活法,两条都错:
34
+ * · 「换身份就收段」⇒ A 说一半、B 插一句、A 说另一半 ⇒ A 被切成两枚权威段,**跨块的凭据正好落在切口上**
35
+ * ——本帧存在的唯一理由当场失效(实测:两枚 `reasoning_end` 拼起来仍是明文密钥);
36
+ * · 「不换就不收」⇒ 两个子代的思考拼成一段,再盖上其中一个的身份键(串味 + 冤枉归属)。
37
+ * 分账之后规则反而**更少**:一条流的段,只被**它自己**的段界或**它自己**的终态收走;别人说什么都与它无关。
38
+ *
39
+ * 状态的界:每条流的条目在它的首块 delta 上建、在收段时删;腿终局 {@link ReasoningSegments.cut} 不带
40
+ * 匹配器 = 全收全删。子代终态那一次带匹配器的收段(转发腿的 `onTaskNotification`)是让**活着的子代数**
41
+ * 而不是「这条 run 一共派过多少子代」成为上界的那一手。
42
+ */
43
+ export interface ReasoningSegments {
44
+ /** 喂一帧 → 本帧收走的段末帧(0 或 1 枚,且**只可能是本帧自己那条流**的段)。返回数组只为让调用点
45
+ * 与 {@link cut} 同形(`for (const f of …) send(f)`),不是「一帧可能收多段」。 */
46
+ onEvent(ev: ReasoningStreamEvent): ReasoningEndFrame[];
47
+ /** 收段:`match` 缺省 = 本腿**全部**流(腿终局 / 句柄终态);给了就只收匹配的那几条(某个子代终态)。
48
+ * 与账本腿 `LedgerSink.onDone → flush()` 同位同义 —— 段界表里没有终局词,终局走这扇门。 */
49
+ cut(match?: (ident: ReasoningStreamIdent) => boolean): ReasoningEndFrame[];
50
+ }
51
+ /** 「这条流属于这些子代 id 之一」—— 子代终态收段的匹配器(`task_id` 与 `sessionId` 是同一个子代的两个
52
+ * id 域,取并集;`undefined` 成员自动忽略)。腿上共用这一只,免得各写一遍 id 域的并集。 */
53
+ export declare function reasoningStreamOfTask(...ids: ReadonlyArray<string | undefined>): (ident: ReasoningStreamIdent) => boolean;
54
+ export declare function createReasoningSegments(): ReasoningSegments;
55
+ //# sourceMappingURL=model-segment.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { reasoningEndEventData } from "./project.js";
2
+ const BOUNDARY = new Set([
3
+ "tool_start", "tool_end", "status", "context_usage", "compacted", "diagnostics",
4
+ "steering_injected", "compaction_outcome", "workspace_changed", "wiring_manifest",
5
+ "tool_roster_delta", "human_input", "task_notification", "turn_end",
6
+ ]);
7
+ export function isModelSegmentBoundary(type) {
8
+ return BOUNDARY.has(type);
9
+ }
10
+ export function reasoningStreamOfTask(...ids) {
11
+ const set = new Set(ids.filter((v) => typeof v === "string" && v !== ""));
12
+ return (ident) => (ident.sourceTaskId !== undefined && set.has(ident.sourceTaskId)) || (ident.bgAgentId !== undefined && set.has(ident.bgAgentId));
13
+ }
14
+ function streamKeyOf(ident) {
15
+ return `${ident.parentToolCallId ?? ""}\u0000${ident.sourceTaskId ?? ""}\u0000${ident.bgAgentId ?? ""}`;
16
+ }
17
+ function identOf(ev) {
18
+ const s = (v) => (typeof v === "string" ? v : undefined);
19
+ return { parentToolCallId: s(ev.parentToolCallId), sourceTaskId: s(ev.sourceTaskId), bgAgentId: s(ev.bgAgentId) };
20
+ }
21
+ export function createReasoningSegments() {
22
+ const open = new Map();
23
+ const cutKey = (key) => {
24
+ const seg = open.get(key);
25
+ if (seg === undefined)
26
+ return [];
27
+ open.delete(key);
28
+ const data = reasoningEndEventData({ ...seg.ident, eventId: seg.eventId, content: seg.buf });
29
+ return data === null ? [] : [{ type: "reasoning_end", ...data }];
30
+ };
31
+ return {
32
+ onEvent: (ev) => {
33
+ if (ev.type === "reasoning_delta") {
34
+ if (typeof ev.delta !== "string" || ev.delta === "")
35
+ return [];
36
+ const ident = identOf(ev);
37
+ const key = streamKeyOf(ident);
38
+ const seg = open.get(key);
39
+ if (seg === undefined)
40
+ open.set(key, { ident, eventId: ev.eventId, buf: ev.delta });
41
+ else
42
+ seg.buf += ev.delta;
43
+ return [];
44
+ }
45
+ return isModelSegmentBoundary(ev.type ?? "") ? cutKey(streamKeyOf(identOf(ev))) : [];
46
+ },
47
+ cut: (match) => {
48
+ const out = [];
49
+ for (const [key, seg] of [...open]) {
50
+ if (match !== undefined && !match(seg.ident))
51
+ continue;
52
+ out.push(...cutKey(key));
53
+ }
54
+ return out;
55
+ },
56
+ };
57
+ }
58
+ //# sourceMappingURL=model-segment.js.map
@@ -194,6 +194,23 @@ export declare function screenedStoredEventData(type: string, data: unknown): un
194
194
  * 坏形(content 非串/空串)⇒ null(丢帧,不铸「权威空段」:core 契约明言空段不发本帧,消费端收到空段会清掉已攒 delta)。
195
195
  * 身份四键零字节变换(live 现状带全四键,消费端可能已依赖)。 */
196
196
  export declare function textEndEventData(ev: Record<string, unknown>): Record<string, unknown> | null;
197
+ /**
198
+ * `reasoning_end` 段末帧的**唯一**铸点(三条 live 腿共用:top stream / 转发子代腿 / 子代 tail 面)——
199
+ * `text_end` 的推理面孪生({@link textEndEventData}),形逐字同(共用 {@link segmentEndFields})。
200
+ *
201
+ * 🔴 **本帧由 server 自铸,core 不发**(`TaskEvent` 上只有 `reasoning_delta`)。`content` = 该段
202
+ * `reasoning_delta` 的累积原文,经与 durable 面**同一只** `redactModelText` —— 这正是本帧存在的理由:
203
+ * 逐 chunk 的 `reasoning_delta` 对**跨 chunk** 的凭据形无能为力(半截 `sk-` 在任何一块里都不成形),
204
+ * 而账本腿攒到 flush 边界的那一整段早就脱得干净;live 面此前没有这份权威副本,壳无可替换。
205
+ * 段界与账本腿**同一只谓词**(`trace/model-segment.ts` 的 `isModelSegmentBoundary`),所以同一次运行里
206
+ * 「账本 `reasoning` 行」与本帧的正文逐字节相等。
207
+ *
208
+ * **空段不发**,判据比 `text_end` 宽一格(那边是 core 契约的「空段不发」,只需判空串):本帧的正文由
209
+ * 本仓攒出,一段**全空白**的思考(模型吐了几个换行就去调工具)没有可替换的权威正文,而消费律是
210
+ * 「整段替换已攒的 delta」—— 发一枚空白权威段 = 让消费端把已渲染的思考清成空白。坏形(`content` 非串)
211
+ * 同样丢帧,理由与 `text_end` 逐字同源。
212
+ */
213
+ export declare function reasoningEndEventData(ev: Record<string, unknown>): Record<string, unknown> | null;
197
214
  /** The durable `tool_end` payload (shared by both append sites). E1: the model-facing `output` is UNTRUSTED RAW
198
215
  * from core (file/shell/API bodies) — core only SIZE-bounds it (`truncated`), REDACTION is ours (same contract as
199
216
  * `tool_start.args`); never re-fed to a model. E2: identity. Absent `output` ⇒ the tool produced no content body.
@@ -176,14 +176,22 @@ function projectSettlement(st) {
176
176
  export function textEndEventData(ev) {
177
177
  if (typeof ev.content !== "string" || ev.content.length === 0)
178
178
  return null;
179
+ return segmentEndFields(ev.content, ev);
180
+ }
181
+ function segmentEndFields(content, ev) {
179
182
  return {
180
- content: redactModelText(ev.content),
183
+ content: redactModelText(content),
181
184
  ...(ev.eventId !== undefined ? { eventId: ev.eventId } : {}),
182
185
  ...(ev.parentToolCallId !== undefined ? { parentToolCallId: ev.parentToolCallId } : {}),
183
186
  ...(ev.sourceTaskId !== undefined ? { sourceTaskId: ev.sourceTaskId } : {}),
184
187
  ...(ev.bgAgentId !== undefined ? { bgAgentId: ev.bgAgentId } : {}),
185
188
  };
186
189
  }
190
+ export function reasoningEndEventData(ev) {
191
+ if (typeof ev.content !== "string" || ev.content.trim().length === 0)
192
+ return null;
193
+ return segmentEndFields(ev.content, ev);
194
+ }
187
195
  export function toolEndEventData(ev) {
188
196
  const gate = ev.gate === undefined ? undefined : screenedGate(ev.gate);
189
197
  return {
@@ -69,7 +69,9 @@ export declare function resetPpkScanBytesForTest(): void;
69
69
  * core [ref] 落地后这一支**趋零是证据、不归零不是证明**。别把它当截断事实的度量用。 */
70
70
  export type RedactionArm = "full" | "fragment";
71
71
  export interface RedactionHitMeta {
72
- /** `"fragment"` = {@link FRAGMENT_SECRET_PATTERNS} 里的前缀残片臂;`"full"` = 完整臂。闭集两词。 */
72
+ /** 命中臂的**判别力档位**(闭集两词,S-303):`"fragment"` = {@link FRAGMENT_SECRET_PATTERNS} 那一族
73
+ * 「短前缀 + 长度/熵阈值」的弱证据臂;`"full"` = 其余全部(结构锚 / 词法关键字 / 定长裸形)。
74
+ * 判据 = 表归属,不是 token 的 `:partial` 后缀 —— 两条轴的分家逐字见 {@link armOfToken}。 */
73
75
  arm: RedactionArm;
74
76
  }
75
77
  export declare function setRedactionObserver(fn: ((patternLabel: string, count: number, meta: RedactionHitMeta) => void) | undefined): void;
@@ -77,6 +79,26 @@ export declare function redactSecrets(text: string, opts?: {
77
79
  observe?: boolean;
78
80
  json?: boolean;
79
81
  }): string;
82
+ /**
83
+ * 脱敏 + **这一遍命中了哪些档位的臂**({@link RedactionArm} 闭集两词)—— 与 {@link redactSecrets} 同一只
84
+ * 脱敏器、同一张表、同一遍扫描的另一个出口。
85
+ *
86
+ * 🔴 **为什么要这个出口**(S-303):下游有一类消费方问的不是「脱完长什么样」而是「这段文本里有没有
87
+ * **强证据**的凭据形」——`tool-approval.ts` 的规则素材闸是头一个。旧形只能拿「字节变了没有」当谓词,
88
+ * 于是弱证据臂(短前缀 + 阈值启发式)的一次多脱就等于一次「素材整只扣下」,用户拿不到常驻放行。
89
+ * 出口开在脱敏器**自己**身上是硬约束:全仓只该有一个「什么算凭据」的属主,消费方**不许**按 token 字面
90
+ * (`includes(":partial»")`)或另跑一遍正则去反推 —— 那就是第二只脱敏器。
91
+ *
92
+ * `arms` 是**这一遍真命中的档位集合**:空集 = 一条臂都没命中(文本可能仍被归一化剥了 `\p{Cf}`,
93
+ * 所以「`text !== 入参`」与「`arms` 非空」不是一回事,后者才是凭据形的证据)。
94
+ */
95
+ export declare function redactSecretsWithArms(text: string, opts?: {
96
+ observe?: boolean;
97
+ json?: boolean;
98
+ }): {
99
+ text: string;
100
+ arms: ReadonlySet<RedactionArm>;
101
+ };
80
102
  /**
81
103
  * 🔴 环 + 深度预算(2026-07-25 补)。原实现是纯递归、**既不记已访问节点也不限深度**,而它的调用位置是
82
104
  * **最不可信的边界**:`toolStart`/`toolEnd` 把工具的 `args`/`output`/`structured` 直接喂进来。后果两条:
@@ -358,7 +358,7 @@ const SHAPE_SECRET_PATTERNS = [
358
358
  [new RegExp(String.raw `xapp-\d-${atLeast("[A-Za-z0-9-]", 20)}`, "g"), "«redacted:slack-token»"],
359
359
  [valueArm("sk-", "[A-Za-z0-9_-]", 16, "self-evident"), "«redacted:key»"],
360
360
  ];
361
- const FULL_SECRET_PATTERNS = [...STRUCTURAL_SECRET_PATTERNS, ...KEYWORD_SECRET_PATTERNS, ...SHAPE_SECRET_PATTERNS];
361
+ const FULL_ARM_PATTERNS = [...STRUCTURAL_SECRET_PATTERNS, ...KEYWORD_SECRET_PATTERNS, ...SHAPE_SECRET_PATTERNS];
362
362
  const PEM_HEADER_IDENT_MAX = 31;
363
363
  const PEM_BODY_RUN_MIN = 8;
364
364
  const isSepCode = (c) => c === CH_SPACE || c === CH_TAB || c === CH_CR || c === CH_LF;
@@ -743,7 +743,7 @@ const FRAGMENT_SECRET_PATTERNS = [
743
743
  [valueArm("(?:gl(?:pat|dt|rt|rtr|cbt|ptt|oas|ft|imt|agent|wt|soat|ffct)-|(?:sk|rk)_(?:live|test|org)_|pypi-)", "[A-Za-z0-9_-]", 8, "needs-left-boundary"), "«redacted:key:partial»"],
744
744
  [valueArm("xapp-\\d-", "[A-Za-z0-9-]", 4, "needs-left-boundary"), "«redacted:slack-token:partial»"],
745
745
  ];
746
- const REDACTION_TOKENS = new Set([...FULL_SECRET_PATTERNS.map(([, r]) => r), PEM_FRAGMENT_TOKEN, ...FRAGMENT_SECRET_PATTERNS.map(([, r]) => r)]
746
+ const REDACTION_TOKENS = new Set([...FULL_ARM_PATTERNS.map(([, r]) => r), PEM_FRAGMENT_TOKEN, ...FRAGMENT_SECRET_PATTERNS.map(([, r]) => r)]
747
747
  .flatMap((r) => r.match(/«[^»]*»/g) ?? []));
748
748
  const REDACTION_TOKEN_MAX = Math.max(...[...REDACTION_TOKENS].map((t) => t.length));
749
749
  {
@@ -761,7 +761,20 @@ function redactionTokenEndAt(s, p, limit) {
761
761
  return 0;
762
762
  }
763
763
  const labelOfToken = (token) => token.replace(/[«»]/g, "").replace(/^redacted:/, "");
764
- const armOfToken = (repl) => (repl.match(/«[^»]*»/g) ?? [repl]).some((t) => t.endsWith(":partial»")) ? "fragment" : "full";
764
+ const FRAGMENT_ARM_TOKENS = new Set(FRAGMENT_SECRET_PATTERNS.map(([, repl]) => repl));
765
+ {
766
+ const partialSuffix = ":partial»";
767
+ const hasPartialToken = (repl) => (repl.match(/«[^»]*»/g) ?? [repl]).some((t) => t.endsWith(partialSuffix));
768
+ for (const [, repl] of FRAGMENT_SECRET_PATTERNS) {
769
+ if (!hasPartialToken(repl))
770
+ throw new Error(`redact: fragment-arm replacement ${JSON.stringify(repl)} must end with ${partialSuffix}`);
771
+ }
772
+ for (const [, repl] of [...STRUCTURAL_SECRET_PATTERNS, ...KEYWORD_SECRET_PATTERNS, ...SHAPE_SECRET_PATTERNS]) {
773
+ if (hasPartialToken(repl))
774
+ throw new Error(`redact: strong-arm replacement ${JSON.stringify(repl)} must not end with ${partialSuffix}`);
775
+ }
776
+ }
777
+ const armOfToken = (repl) => (FRAGMENT_ARM_TOKENS.has(repl) ? "fragment" : "full");
765
778
  const NO_HITS = [];
766
779
  const singleHit = (label, arm, r) => r.count > 0 ? { out: r.out, hits: [{ label, count: r.count, arm }] } : { out: r.out, hits: NO_HITS };
767
780
  const regexRule = ([re, repl]) => {
@@ -789,9 +802,9 @@ const regexRule = ([re, repl]) => {
789
802
  return { run: (text, wantCount) => singleHit(label, arm, applyOnce(text, wantCount)) };
790
803
  };
791
804
  const URL_USERINFO_PATTERN = (() => {
792
- const row = FULL_SECRET_PATTERNS.find(([, repl]) => repl.endsWith("»@"));
805
+ const row = FULL_ARM_PATTERNS.find(([, repl]) => repl.endsWith("»@"));
793
806
  if (row === undefined)
794
- throw new Error("redact: URL-userinfo rule missing from FULL_SECRET_PATTERNS");
807
+ throw new Error("redact: URL-userinfo rule missing from FULL_ARM_PATTERNS");
795
808
  return row;
796
809
  })();
797
810
  const isRegexSpace = (c) => c === 32 || (c >= 9 && c <= 13) || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff;
@@ -1021,10 +1034,24 @@ const armPoolRule = (() => {
1021
1034
  const out = applySpans(text, kept).out;
1022
1035
  if (!wantCount || kept.length === 0)
1023
1036
  return { out, hits: NO_HITS };
1024
- const byToken = new Map();
1025
- for (const sp of kept)
1026
- byToken.set(sp.repl, (byToken.get(sp.repl) ?? 0) + 1);
1027
- return { out, hits: [...byToken].map(([token, count]) => ({ label: labelOfToken(token), count, arm: armOfToken(token) })) };
1037
+ const strong = spans.filter((sp) => armOfToken(sp.repl) === "full").sort((a, b) => a.start - b.start);
1038
+ const byArmedToken = new Map();
1039
+ let si = 0;
1040
+ let strongMaxEnd = -1;
1041
+ for (const sp of kept) {
1042
+ while (si < strong.length && strong[si].start < sp.end) {
1043
+ strongMaxEnd = Math.max(strongMaxEnd, strong[si].end);
1044
+ si += 1;
1045
+ }
1046
+ const arm = strongMaxEnd > sp.start ? "full" : armOfToken(sp.repl);
1047
+ const key = `${sp.repl}\u0000${arm}`;
1048
+ const cur = byArmedToken.get(key);
1049
+ if (cur === undefined)
1050
+ byArmedToken.set(key, { token: sp.repl, arm, count: 1 });
1051
+ else
1052
+ cur.count += 1;
1053
+ }
1054
+ return { out, hits: [...byArmedToken.values()].map(({ token, arm, count }) => ({ label: labelOfToken(token), count, arm })) };
1028
1055
  },
1029
1056
  };
1030
1057
  })();
@@ -1063,13 +1090,20 @@ function reportHits(hits) {
1063
1090
  }
1064
1091
  }
1065
1092
  }
1066
- export function redactSecrets(text, opts) {
1067
- const wantCount = onRedaction !== undefined && (opts?.observe ?? true);
1068
- const json = opts?.json === true;
1069
- const r = applyRules(normalizeForRedaction(text), wantCount, json, opts?.observe ?? true);
1093
+ function runRedaction(text, observe, json, wantHits) {
1094
+ const wantCount = wantHits || (onRedaction !== undefined && observe);
1095
+ const r = applyRules(normalizeForRedaction(text), wantCount, json, observe);
1070
1096
  forgetEscapedSeparatorTails();
1071
- reportHits(r.hits);
1072
- return r.out;
1097
+ if (observe)
1098
+ reportHits(r.hits);
1099
+ return { text: r.out, hits: r.hits };
1100
+ }
1101
+ export function redactSecrets(text, opts) {
1102
+ return runRedaction(text, opts?.observe ?? true, opts?.json === true, false).text;
1103
+ }
1104
+ export function redactSecretsWithArms(text, opts) {
1105
+ const r = runRedaction(text, opts?.observe ?? true, opts?.json === true, true);
1106
+ return { text: r.text, arms: new Set(r.hits.map((h) => h.arm)) };
1073
1107
  }
1074
1108
  const REDACT_MAX_DEPTH = 64;
1075
1109
  export function redactDeep(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.76.1",
3
+ "version": "7.77.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",