dsh-plugin-soulbridge 0.0.112 → 0.0.114
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 +24 -0
- package/index.js +184 -8
- 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,29 @@ 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
|
+
里没有父子关系,猜一个比写两条更坏。
|
|
111
|
+
|
|
112
|
+
插件**不算钱**(价目有峰谷两档且会变)。账单是另一件事,也只属于宿主侧的运维:
|
|
113
|
+
|
|
114
|
+
```powershell
|
|
115
|
+
node scripts/billing.mjs --days 4 # 读平台导出,按天给金额/请求/三类 token 并复核单价
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
判读与限制见 [docs/cost-visibility.md](docs/cost-visibility.md)。这两样都**不属于 soulbridge**:
|
|
119
|
+
记忆层存"发生了什么",不记"值多少钱"。
|
|
120
|
+
|
|
97
121
|
## 升级提示:新增工具需要新开会话
|
|
98
122
|
|
|
99
123
|
`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
|
}
|
|
@@ -501,7 +663,7 @@ export function buildFactHooks(text, options = {}) {
|
|
|
501
663
|
for (const fact of [...earliest, ...recent]) {
|
|
502
664
|
if (seen.has(fact.id)) continue;
|
|
503
665
|
seen.add(fact.id);
|
|
504
|
-
const when = fact.
|
|
666
|
+
const when = fact.validFromText ?? (fact.validFrom ? new Date(fact.validFrom).toISOString().slice(0, 10) : '');
|
|
505
667
|
// ADR 0035: a fact from my side of the exchange is inferred. Two characters plus one
|
|
506
668
|
// explanation below the block, because a per-line sentence would double the text.
|
|
507
669
|
const marker = fact.source === 'inferred' ? INFERRED_MARKER : '';
|
|
@@ -648,7 +810,7 @@ function buildRelatedContext(text, state = {}) {
|
|
|
648
810
|
const body = timeline
|
|
649
811
|
.map(
|
|
650
812
|
(fact) =>
|
|
651
|
-
`- [${fact.id}] ${fact.
|
|
813
|
+
`- [${fact.id}] ${fact.validFromText ? `${escapeBlockText(fact.validFromText)} ` : ''}${escapeBlockText(fact.statement)}`,
|
|
652
814
|
)
|
|
653
815
|
.join('\n');
|
|
654
816
|
parts.push(`<about-now name="${escapeAttr(plan.subject)}">\n${body}\n</about-now>`);
|
|
@@ -1944,7 +2106,7 @@ async function runConsolidationStep() {
|
|
|
1944
2106
|
const batch = context.facts.slice(0, CONSOLIDATION_MAX_FACTS).map((fact) => ({
|
|
1945
2107
|
id: fact.id,
|
|
1946
2108
|
statement: fact.statement,
|
|
1947
|
-
|
|
2109
|
+
validFromText: fact.validFromText,
|
|
1948
2110
|
}));
|
|
1949
2111
|
let raw;
|
|
1950
2112
|
try {
|
|
@@ -2189,8 +2351,9 @@ function detailTool() {
|
|
|
2189
2351
|
properties: {
|
|
2190
2352
|
ok: { type: 'boolean' },
|
|
2191
2353
|
statement: { type: 'string' },
|
|
2192
|
-
|
|
2193
|
-
|
|
2354
|
+
validFrom: { type: 'number' },
|
|
2355
|
+
validFromText: { type: 'string' },
|
|
2356
|
+
validTo: { type: 'number' },
|
|
2194
2357
|
userText: { type: 'string' },
|
|
2195
2358
|
assistantText: { type: 'string' },
|
|
2196
2359
|
reason: { type: 'string' },
|
|
@@ -2211,8 +2374,9 @@ function detailTool() {
|
|
|
2211
2374
|
return {
|
|
2212
2375
|
ok: true,
|
|
2213
2376
|
statement: detail.fact.statement,
|
|
2214
|
-
|
|
2215
|
-
|
|
2377
|
+
validFrom: detail.fact.validFrom,
|
|
2378
|
+
validFromText: detail.fact.validFromText,
|
|
2379
|
+
validTo: detail.fact.validTo,
|
|
2216
2380
|
userText: detail.turn?.userText,
|
|
2217
2381
|
assistantText: detail.turn?.assistantText,
|
|
2218
2382
|
};
|
|
@@ -2280,6 +2444,14 @@ export function apply(ctx) {
|
|
|
2280
2444
|
// Backfill is triggered by the first user message, not here: at apply() time
|
|
2281
2445
|
// the host model service is not mounted yet and every call fails.
|
|
2282
2446
|
|
|
2447
|
+
// Per-turn cost (0026): a turn's record goes to trace.jsonl, and a turn that
|
|
2448
|
+
// reaches the threshold also gets one line in plugin.log. Attached before the
|
|
2449
|
+
// event listener so no turn can be missed.
|
|
2450
|
+
turnCostTracker = createTurnCostTracker({
|
|
2451
|
+
onReport: (record) => trace(record),
|
|
2452
|
+
onAlert: (line) => log(line),
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2283
2455
|
try {
|
|
2284
2456
|
ctx.effect(
|
|
2285
2457
|
() => {
|
|
@@ -2366,6 +2538,10 @@ export function apply(ctx) {
|
|
|
2366
2538
|
|
|
2367
2539
|
const sessionId = session?.id ?? 'default';
|
|
2368
2540
|
|
|
2541
|
+
// Before any early return below: turn/end is where the cost record is
|
|
2542
|
+
// written, and that branch returns.
|
|
2543
|
+
turnCostTracker?.observe(sessionId, event);
|
|
2544
|
+
|
|
2369
2545
|
// Keep the last reply: a correction only makes sense next to what it targets.
|
|
2370
2546
|
if (typeof event.data?.turn === 'number') currentTurn = event.data.turn;
|
|
2371
2547
|
if (typeof event.data?.step === 'number') currentStep = event.data.step;
|