dsh-plugin-soulbridge 0.0.112 → 0.0.113

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 (3) hide show
  1. package/README.md +15 -0
  2. package/index.js +175 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,6 +45,7 @@ dsh plugin --profile desktop add "github:Jason0826/dsh-plugin-soulbridge#v0.0.4"
45
45
  | `user/message source=user chars=N` | 收到用户消息 |
46
46
  | `soul ready: db=… soul=… user=…` | core 与数据库就绪 |
47
47
  | `observe ok: chars=… written=… affect=… confidence=…` | **整条链路成功** |
48
+ | `turn cost: session=… turn=… requests=N (steps=… failed=… plugin=…)` | 这一轮的请求数到了 30(见下节) |
48
49
  | `skipping profile bundle …` (stderr) | bundle 被启动器跳过,原因就在后面 |
49
50
 
50
51
  ## 卸载
@@ -94,6 +95,20 @@ DSH 启动器对 `package.json` 直接 `JSON.parse`,不剥 BOM。用 Windows P
94
95
  为什么是"规则优先、模型补漏":实测规则精确率 1.00,但 26 条真实消息里漏了 19 条;
95
96
  让模型覆盖规则没有收益,只在漏掉时补才有。宿主模型**只能在真实轮次内调用**,
96
97
  启动时调用会被账号 provider 拒绝(见 `docs/experiment-2026-10-29-host-llm.md`)。
98
+
99
+ ## 每轮花了多少(0026)
100
+
101
+ 平台导出只有"每天一行",单轮花大钱看不出来。所以插件自己记:
102
+
103
+ | 落点 | 什么时候 | 内容 |
104
+ |---|---|---|
105
+ | `trace.jsonl` 的 `{"event":"turn-cost"}` | 每轮结束 | 请求数(= 步骤 + 失败尝试 + 插件自己的调用)、四类 token、耗时、结束原因 |
106
+ | `plugin.log` 的 `turn cost:` | 一轮 ≥ 30 次请求 | 同一份数字压成一行 |
107
+
108
+ 计数来源是宿主的事件流:`step/start`(一次调用)、`assistant/attempt`(重试且没出消息)、
109
+ `assistant/message.usage`(token)。子代理跑在自己的会话里,因此各自一条记录——事件流
110
+ 里没有父子关系,猜一个比写两条更坏。插件**不算钱**:金额只从 `scripts/billing.mjs`
111
+ (读平台导出)来,价目有峰谷两档且会变。
97
112
  ## 升级提示:新增工具需要新开会话
98
113
 
99
114
  `ctx.inject(['tools'])` 注册的工具在**会话创建时**进入该会话的工具集。插件升级后
package/index.js CHANGED
@@ -88,6 +88,151 @@ function trace(record) {
88
88
  }
89
89
  }
90
90
 
91
+ /** A turn that reaches this many model requests gets one line in plugin.log (0026). */
92
+ const TURN_REQUEST_ALERT = 30;
93
+ /** Subagent sessions each open a turn; past this many, the oldest unfinished turn is written out. */
94
+ const TURN_TRACKER_MAX = 64;
95
+
96
+ /**
97
+ * What one turn cost, in requests and tokens (brief 0026).
98
+ *
99
+ * The platform export is one line per day, so the turn that actually burned the
100
+ * money is invisible until the day is over. The host's event stream already
101
+ * carries everything needed to attribute it:
102
+ *
103
+ * step/start one model call was scheduled
104
+ * assistant/attempt a call that was retried and produced no message
105
+ * assistant/message a finished call, with the usage it reported
106
+ * turn/start, turn/end the window
107
+ *
108
+ * A subagent runs in its own session and therefore gets its own record; the
109
+ * event stream has no parent link, and guessing one would be worse than two
110
+ * honest lines. Money is deliberately absent — prices have peak/off-peak tiers
111
+ * and change, so amounts come only from `scripts/billing.mjs` in core.
112
+ *
113
+ * @param {object} [options]
114
+ * @param {number} [options.threshold] request count that triggers the log line.
115
+ * @param {number} [options.maxTurns] unfinished turns kept before the oldest is written out.
116
+ * @param {(record: object) => void} [options.onReport] called once per finished turn.
117
+ * @param {(line: string) => void} [options.onAlert] called when a turn reaches the threshold.
118
+ */
119
+ export function createTurnCostTracker({ threshold = TURN_REQUEST_ALERT, maxTurns = TURN_TRACKER_MAX, onReport, onAlert } = {}) {
120
+ const open = new Map();
121
+ let lastSession;
122
+
123
+ const addUsage = (entry, usage) => {
124
+ if (!usage) return;
125
+ entry.input += Number(usage.inputTokens) || 0;
126
+ entry.cacheHit += Number(usage.cacheReadTokens) || 0;
127
+ entry.cacheWrite += Number(usage.cacheWriteTokens) || 0;
128
+ entry.output += Number(usage.outputTokens) || 0;
129
+ };
130
+
131
+ const requestsOf = (entry) => entry.steps + entry.failed + entry.plugin;
132
+
133
+ /** Write the record once, then forget the turn. `complete` is false for interrupted/evicted turns. */
134
+ const finish = (session, entry, reason, endedAt, complete) => {
135
+ open.delete(session);
136
+ const requests = requestsOf(entry);
137
+ if (requests === 0) return;
138
+ const secs = Math.max(0, Math.round((endedAt - entry.startedAt) / 1000));
139
+ const record = {
140
+ event: 'turn-cost',
141
+ session,
142
+ turn: entry.turn,
143
+ requests,
144
+ steps: entry.steps,
145
+ failed: entry.failed,
146
+ plugin: entry.plugin,
147
+ input: entry.input,
148
+ cacheHit: entry.cacheHit,
149
+ cacheWrite: entry.cacheWrite,
150
+ output: entry.output,
151
+ secs,
152
+ ...(complete ? {} : { open: true }),
153
+ ...(reason === undefined ? {} : { reason }),
154
+ };
155
+ onReport?.(record);
156
+ if (requests >= threshold) {
157
+ onAlert?.(
158
+ `turn cost: session=${session} turn=${entry.turn} requests=${requests}` +
159
+ ` (steps=${entry.steps} failed=${entry.failed} plugin=${entry.plugin})` +
160
+ ` input=${entry.input} cacheHit=${entry.cacheHit} output=${entry.output}` +
161
+ ` secs=${secs}${complete ? '' : ' open'}`,
162
+ );
163
+ }
164
+ };
165
+
166
+ return {
167
+ /**
168
+ * Feed one session event. Events of a session whose turn was never opened are
169
+ * ignored rather than attached to a neighbour: a wrong attribution is worse
170
+ * than a missing one.
171
+ * @param {string} session - session id the event belongs to.
172
+ * @param {{type?: string, time?: number, data?: object}} event - host session event.
173
+ */
174
+ observe(session, event) {
175
+ const type = event?.type;
176
+ const data = event?.data ?? {};
177
+ if (typeof data.turn !== 'number') return;
178
+ lastSession = session;
179
+ const entry = open.get(session);
180
+ if (type === 'turn/start') {
181
+ if (entry) finish(session, entry, 'restarted', event?.time ?? Date.now(), false);
182
+ open.set(session, {
183
+ turn: data.turn,
184
+ startedAt: event?.time ?? Date.now(),
185
+ steps: 0,
186
+ failed: 0,
187
+ plugin: 0,
188
+ input: 0,
189
+ cacheHit: 0,
190
+ cacheWrite: 0,
191
+ output: 0,
192
+ });
193
+ if (open.size > maxTurns) {
194
+ const oldest = open.keys().next().value;
195
+ finish(oldest, open.get(oldest), 'evicted', Date.now(), false);
196
+ }
197
+ return;
198
+ }
199
+ if (!entry || entry.turn !== data.turn) return;
200
+ if (type === 'step/start') entry.steps += 1;
201
+ else if (type === 'assistant/attempt') entry.failed += 1;
202
+ else if (type === 'assistant/message') addUsage(entry, data.usage);
203
+ else if (type === 'turn/end') {
204
+ finish(session, entry, data.reason?.kind, event?.time ?? Date.now(), true);
205
+ }
206
+ },
207
+ /**
208
+ * Count one model call made by this plugin, attributed to the turn it ran
209
+ * inside. The turn is the most recent one the host reported an event for —
210
+ * the stage chain only runs while that turn is live.
211
+ */
212
+ pluginCall() {
213
+ if (lastSession === undefined) return;
214
+ const entry = open.get(lastSession);
215
+ if (!entry) return;
216
+ entry.plugin += 1;
217
+ },
218
+ /**
219
+ * Add the tokens a plugin call reported to the same turn. Separate from
220
+ * {@link pluginCall} because the request is counted when it is dispatched
221
+ * (a failed call still costs money) while its usage only arrives at the end.
222
+ * @param {{inputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, outputTokens?: number}} [usage]
223
+ * usage the call reported; absent when the call failed before reporting.
224
+ */
225
+ pluginUsage(usage) {
226
+ if (lastSession === undefined) return;
227
+ const entry = open.get(lastSession);
228
+ if (!entry) return;
229
+ addUsage(entry, usage);
230
+ },
231
+ /** Unfinished turns currently held (observability for the bound in tests). */
232
+ openCount: () => open.size,
233
+ };
234
+ }
235
+
91
236
  /** Prompt assembly can run several times per step; do not hit SQLite every time. */
92
237
 
93
238
  /**
@@ -134,6 +279,8 @@ function readConfig() {
134
279
  /** Adapter side of the core LlmFn port: the host model, called inside a turn. */
135
280
  let currentTurn;
136
281
  let currentStep;
282
+ /** Per-turn cost accounting (0026); null until apply() attaches it (tests import this module bare). */
283
+ let turnCostTracker;
137
284
 
138
285
  // Measured, not guessed: 279 calls, p50=1.7s p90=6.8s p99=46.7s, max=142.9s.
139
286
  // 90s is ~2x p99 and caps a hung call without cutting the ordinary tail.
@@ -170,6 +317,11 @@ export function makeLlm(ctx, config) {
170
317
  // a call actually lands in.
171
318
  const started = Date.now();
172
319
  log(`llm probe: start turn=${currentTurn ?? '?'} step=${currentStep ?? '?'} chars=${prompt.length}`);
320
+ // This call is a model request too (0026): count it, and keep the usage the
321
+ // stream reports so the turn's token total is complete rather than just its
322
+ // agent-loop part.
323
+ let usage;
324
+ turnCostTracker?.pluginCall();
173
325
  try {
174
326
  const text = await withTimeout(
175
327
  collectText(
@@ -185,10 +337,14 @@ export function makeLlm(ctx, config) {
185
337
  },
186
338
  ],
187
339
  }),
340
+ (reported) => {
341
+ usage = reported;
342
+ },
188
343
  ),
189
344
  LLM_TIMEOUT_MS,
190
345
  'llm call',
191
346
  );
347
+ turnCostTracker?.pluginUsage(usage);
192
348
  log(
193
349
  `llm probe: end ok ms=${Date.now() - started} out=${typeof text === 'string' ? text.length : -1}` +
194
350
  ` step=${currentStep ?? '?'}`,
@@ -215,8 +371,12 @@ export function makeLlm(ctx, config) {
215
371
  * `finish{reason:{kind:'error'|'aborted'}}` instead of throwing, so without this
216
372
  * check the failure came back as `''` and the caller read it as "the model said
217
373
  * nothing". `collectText` throws unless some text was produced before the failure.
374
+ *
375
+ * @param {AsyncIterable<object>} stream - chunks of one model call.
376
+ * @param {(usage: object) => void} [onUsage] called with the usage chunk, if the
377
+ * adapter reported one; without it a call's tokens have no reader (0026).
218
378
  */
219
- export async function collectText(stream) {
379
+ export async function collectText(stream, onUsage) {
220
380
  let text = '';
221
381
  let sawDelta = false;
222
382
  let failure;
@@ -226,6 +386,8 @@ export async function collectText(stream) {
226
386
  sawDelta = true;
227
387
  } else if (!sawDelta && chunk.type === 'block-end' && chunk.block?.type === 'text') {
228
388
  text += chunk.block.text ?? '';
389
+ } else if (chunk.type === 'usage' && chunk.usage) {
390
+ onUsage?.(chunk.usage);
229
391
  } else if (chunk.type === 'finish' && (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted')) {
230
392
  failure = chunk.reason;
231
393
  }
@@ -2280,6 +2442,14 @@ export function apply(ctx) {
2280
2442
  // Backfill is triggered by the first user message, not here: at apply() time
2281
2443
  // the host model service is not mounted yet and every call fails.
2282
2444
 
2445
+ // Per-turn cost (0026): a turn's record goes to trace.jsonl, and a turn that
2446
+ // reaches the threshold also gets one line in plugin.log. Attached before the
2447
+ // event listener so no turn can be missed.
2448
+ turnCostTracker = createTurnCostTracker({
2449
+ onReport: (record) => trace(record),
2450
+ onAlert: (line) => log(line),
2451
+ });
2452
+
2283
2453
  try {
2284
2454
  ctx.effect(
2285
2455
  () => {
@@ -2366,6 +2536,10 @@ export function apply(ctx) {
2366
2536
 
2367
2537
  const sessionId = session?.id ?? 'default';
2368
2538
 
2539
+ // Before any early return below: turn/end is where the cost record is
2540
+ // written, and that branch returns.
2541
+ turnCostTracker?.observe(sessionId, event);
2542
+
2369
2543
  // Keep the last reply: a correction only makes sense next to what it targets.
2370
2544
  if (typeof event.data?.turn === 'number') currentTurn = event.data.turn;
2371
2545
  if (typeof event.data?.step === 'number') currentStep = event.data.step;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-soulbridge",
3
- "version": "0.0.112",
3
+ "version": "0.0.113",
4
4
  "description": "Inject soulbridge context into DSH sessions and feed user turns back as observations.",
5
5
  "type": "module",
6
6
  "main": "index.js",