dsh-plugin-soulbridge 0.0.111 → 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 +219 -9
  3. package/package.json +2 -2
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
  }
@@ -1098,6 +1260,17 @@ function bumpExtractAttempt(turnId) {
1098
1260
  );
1099
1261
  }
1100
1262
 
1263
+ /**
1264
+ * After a thread was extracted, every queued turn of that thread is already covered.
1265
+ * Leaving them would extract the same topic again, turn by turn (0025).
1266
+ */
1267
+ function removeExtractItemsOfThread(threadId, turnIds) {
1268
+ if (!threadId) return;
1269
+ writeExtractQueue(
1270
+ readExtractQueue().filter((item) => !turnIds.includes(item.turnId)),
1271
+ );
1272
+ }
1273
+
1101
1274
  /**
1102
1275
  * Add one turn to the extraction queue.
1103
1276
  *
@@ -1131,7 +1304,7 @@ function hasSubstance(text) {
1131
1304
  return !/^(好的?|嗯+|哦+|可以|行|ok|okay|收到|明白|继续|好)[。!!.\s]*$/i.test(trimmed);
1132
1305
  }
1133
1306
 
1134
- async function runExtraction(text, turnId, turnNo, origin = 'live') {
1307
+ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = null) {
1135
1308
  const config = readConfig();
1136
1309
  if (config.extract === 'off') return 'skipped-off';
1137
1310
  if (typeof text !== 'string' || !hasSubstance(text)) {
@@ -1153,6 +1326,10 @@ async function runExtraction(text, turnId, turnNo, origin = 'live') {
1153
1326
  const previousThreadId = soul.threadBefore(turnRow.sessionId, turnNo);
1154
1327
  if (previousThreadId) context.previousThreadId = previousThreadId;
1155
1328
  }
1329
+ // 0025: the unit is the whole topic when the turn belongs to one. The turns array is
1330
+ // numbered the way the prompt numbers them, and each fact may name its own turn.
1331
+ const turns = thread?.turns ?? null;
1332
+ const turnIds = turns ? Object.fromEntries(turns.map((entry) => [entry.turnNo, entry.id])) : undefined;
1156
1333
  const draft = await soul.extract(text, {
1157
1334
  llm: useLlm ? injectedLlm : undefined,
1158
1335
  mode: useLlm ? 'auto' : 'rules',
@@ -1162,8 +1339,9 @@ async function runExtraction(text, turnId, turnNo, origin = 'live') {
1162
1339
  // The exchange, not the message (ADR 0035): my reply is where promises and
1163
1340
  // decisions live, and it is the only way "我的承诺" can ever be extracted.
1164
1341
  assistantText: turnRow?.assistantText ?? undefined,
1342
+ turns: turns ?? undefined,
1165
1343
  });
1166
- const result = soul.commitDrafts(draft, { turnId });
1344
+ const result = soul.commitDrafts(draft, { turnId, turnIds });
1167
1345
  // Attribution is written even when nothing was extracted: a turn that says
1168
1346
  // "无" (nothing worth remembering) can still be part of a thread.
1169
1347
  const attribution = soul.applyThread(draft.thread, {
@@ -1183,6 +1361,7 @@ async function runExtraction(text, turnId, turnNo, origin = 'live') {
1183
1361
  ` gaps=${result.gaps.length} skipped=${result.skipped.length}` +
1184
1362
  ` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
1185
1363
  ` inferred=${inferred}` +
1364
+ ` unit=${turns ? `${turns.length}turns` : '1turn'} threadId=${thread?.id ?? '-'}` +
1186
1365
  ` ms=${ms} turn=${turnNo} origin=${origin}` +
1187
1366
  ` thread=${attribution.threadId}${attribution.created ? '(new)' : ''}` +
1188
1367
  ` join=${attribution.joinReason} threadLine=${draft.threadLine ?? 'rules'}` +
@@ -1276,9 +1455,21 @@ export async function drainExtractionQueue() {
1276
1455
  try {
1277
1456
  const item = pickExtractionItem(queue);
1278
1457
  let text = '';
1458
+ let thread = null;
1279
1459
  try {
1280
- const turn = ensureSoul().turn(item.turnId);
1460
+ const soul = ensureSoul();
1461
+ const turn = soul.turn(item.turnId);
1281
1462
  text = turn?.userText ?? '';
1463
+ // A turn is a trigger, the topic is the unit (0025). When the turn is already in a
1464
+ // thread, extract that thread's turns instead of this message alone.
1465
+ const threadId = soul.turnThreadId(item.turnId);
1466
+ if (threadId) {
1467
+ const turns = soul.threadTurns(threadId);
1468
+ if (turns.length > 0) {
1469
+ thread = { id: threadId, turns };
1470
+ text = turns[turns.length - 1].userText;
1471
+ }
1472
+ }
1282
1473
  } catch {
1283
1474
  text = '';
1284
1475
  }
@@ -1288,14 +1479,21 @@ export async function drainExtractionQueue() {
1288
1479
  return;
1289
1480
  }
1290
1481
 
1291
- const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live');
1482
+ const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live', thread);
1292
1483
  const ok =
1293
1484
  outcome === 'ok' || outcome === 'skipped-off' || outcome === 'skipped-empty' || outcome === 'rules-mode';
1294
1485
  if (ok) {
1295
- removeExtractItem(item.turnId);
1296
- // Promotion is decided after the facts exist: a corrected turn's lessons enter
1297
- // the classification queue here, which is the only live producer it has.
1298
- feedClassifyFromTurn(item.turnId);
1486
+ if (thread) {
1487
+ removeExtractItemsOfThread(thread.id, thread.turns.map((entry) => entry.id));
1488
+ // Promotion is decided after the facts exist; a whole thread can hold several
1489
+ // corrected turns, so every turn is offered.
1490
+ for (const entry of thread.turns) feedClassifyFromTurn(entry.id);
1491
+ } else {
1492
+ removeExtractItem(item.turnId);
1493
+ // Promotion is decided after the facts exist: a corrected turn's lessons enter
1494
+ // the classification queue here, which is the only live producer it has.
1495
+ feedClassifyFromTurn(item.turnId);
1496
+ }
1299
1497
  trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome });
1300
1498
  return true;
1301
1499
  }
@@ -2244,6 +2442,14 @@ export function apply(ctx) {
2244
2442
  // Backfill is triggered by the first user message, not here: at apply() time
2245
2443
  // the host model service is not mounted yet and every call fails.
2246
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
+
2247
2453
  try {
2248
2454
  ctx.effect(
2249
2455
  () => {
@@ -2330,6 +2536,10 @@ export function apply(ctx) {
2330
2536
 
2331
2537
  const sessionId = session?.id ?? 'default';
2332
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
+
2333
2543
  // Keep the last reply: a correction only makes sense next to what it targets.
2334
2544
  if (typeof event.data?.turn === 'number') currentTurn = event.data.turn;
2335
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.111",
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",
@@ -42,6 +42,6 @@
42
42
  "setup": "node scripts/install-hooks.mjs"
43
43
  },
44
44
  "dependencies": {
45
- "soulbridge": ">=0.0.108 <0.1.0"
45
+ "soulbridge": ">=0.0.112 <0.1.0"
46
46
  }
47
47
  }