dsh-plugin-soulbridge 0.0.117 → 0.0.119
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/index.js +130 -30
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -233,6 +233,70 @@ export function createTurnCostTracker({ threshold = TURN_REQUEST_ALERT, maxTurns
|
|
|
233
233
|
};
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* When a session has grown past the point where starting a new one is cheaper (0026).
|
|
238
|
+
*
|
|
239
|
+
* The signal is prompt tokens per request — how much history every single call re-buys —
|
|
240
|
+
* with the turn count as the second axis. 300k is where the old session degraded; 100
|
|
241
|
+
* turns is where continuing stops being cheap even at a smaller context.
|
|
242
|
+
*/
|
|
243
|
+
const SWITCH_SESSION = { contextPerRequest: 300_000, turns: 100 };
|
|
244
|
+
|
|
245
|
+
/** The reminder the model relays: the reply is the only channel the user reads. */
|
|
246
|
+
export function renderSwitchSession(stats, perRequest) {
|
|
247
|
+
const thousands = Math.round(perRequest / 1000);
|
|
248
|
+
return (
|
|
249
|
+
`<switch-session turns="${stats.turns}" context-per-request="${Math.round(perRequest)}">` +
|
|
250
|
+
`这个会话已经很大(每请求约 ${thousands}k token 上下文,共 ${stats.turns} 轮)。` +
|
|
251
|
+
`请提醒用户:新开一个会话更省——先把进度写进 docs/state.md 的接手须知,` +
|
|
252
|
+
`再到新会话用「读 docs/state.md 的接手须知」开头。` +
|
|
253
|
+
`</switch-session>`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Per-session cost ledger. It answers one question: has this session crossed the point
|
|
259
|
+
* where a new one is cheaper, and has the user been told yet?
|
|
260
|
+
*
|
|
261
|
+
* Not a nag: the reminder fires once per level, and a level is one more notch of the
|
|
262
|
+
* same threshold (600k/req or 200 turns is level 2). Crossing again by a doubling is
|
|
263
|
+
* worth saying again; saying it every turn is wallpaper.
|
|
264
|
+
*/
|
|
265
|
+
export function createSessionLedger({ thresholds = SWITCH_SESSION } = {}) {
|
|
266
|
+
const cost = new Map();
|
|
267
|
+
const reminded = new Map();
|
|
268
|
+
return {
|
|
269
|
+
/** Feed one finished turn — the turn-cost report. */
|
|
270
|
+
note(record) {
|
|
271
|
+
const id = String(record?.session ?? 'default');
|
|
272
|
+
const stats = cost.get(id) ?? { turns: 0, requests: 0, prompt: 0 };
|
|
273
|
+
stats.turns += 1;
|
|
274
|
+
stats.requests += Number(record?.requests) || 0;
|
|
275
|
+
stats.prompt +=
|
|
276
|
+
(Number(record?.input) || 0) + (Number(record?.cacheHit) || 0) + (Number(record?.cacheWrite) || 0);
|
|
277
|
+
cost.set(id, stats);
|
|
278
|
+
// Bounded like the tracker: a long-lived host must not accumulate sessions forever.
|
|
279
|
+
if (cost.size > 16) cost.delete(cost.keys().next().value);
|
|
280
|
+
},
|
|
281
|
+
/** The reminder block when a new level is crossed; '' otherwise. */
|
|
282
|
+
due(sessionId) {
|
|
283
|
+
const id = String(sessionId ?? 'default');
|
|
284
|
+
const stats = cost.get(id);
|
|
285
|
+
if (!stats || stats.requests === 0) return '';
|
|
286
|
+
const perRequest = stats.prompt / stats.requests;
|
|
287
|
+
const level = Math.floor(
|
|
288
|
+
Math.max(perRequest / thresholds.contextPerRequest, stats.turns / thresholds.turns),
|
|
289
|
+
);
|
|
290
|
+
if (level < 1 || level <= (reminded.get(id) ?? 0)) return '';
|
|
291
|
+
reminded.set(id, level);
|
|
292
|
+
return renderSwitchSession(stats, perRequest);
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** One ledger for the running host: a session is remembered across pre-steps. */
|
|
298
|
+
const sessionLedger = createSessionLedger();
|
|
299
|
+
|
|
236
300
|
/** Prompt assembly can run several times per step; do not hit SQLite every time. */
|
|
237
301
|
|
|
238
302
|
/**
|
|
@@ -989,42 +1053,64 @@ export async function runStageChain(stages) {
|
|
|
989
1053
|
return false;
|
|
990
1054
|
}
|
|
991
1055
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1056
|
+
/**
|
|
1057
|
+
* The handoff consumer: at most one NON-EMPTY block per session.
|
|
1058
|
+
*
|
|
1059
|
+
* Deliberately once per session and only when the topic belongs to a
|
|
1060
|
+
* different session — repeating it every turn would make it wallpaper, and
|
|
1061
|
+
* re-reading the previous session's own topic is not a handoff.
|
|
1062
|
+
*
|
|
1063
|
+
* The read is injected so tests can drive the consumption contract without a
|
|
1064
|
+
* database. An EMPTY read does NOT consume the slot: a new session's first
|
|
1065
|
+
* turn often reads empty because the previous segment has not been flushed
|
|
1066
|
+
* yet (0028 decision 4 — handoff is a render, not a one-shot trigger), and
|
|
1067
|
+
* forfeiting then would drop the one chance this session has to be told where
|
|
1068
|
+
* it came from. A throwing read also leaves the slot open, as before.
|
|
1069
|
+
*/
|
|
1070
|
+
export function createHandoffTaker(readShot, logger, tracer) {
|
|
1071
|
+
/** Sessions that already received a non-empty handoff block. */
|
|
996
1072
|
const handedOff = new Set();
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* The "where were we" block: the last topic that finished.
|
|
1000
|
-
*
|
|
1001
|
-
* Deliberately once per session and only when that topic belongs to a
|
|
1002
|
-
* different session — repeating it every turn would make it wallpaper, and
|
|
1003
|
-
* re-reading the previous session's own topic is not a handoff.
|
|
1004
|
-
*/
|
|
1005
|
-
function takeHandoff(sessionId) {
|
|
1073
|
+
return (sessionId) => {
|
|
1006
1074
|
if (handedOff.has(sessionId)) return '';
|
|
1007
1075
|
try {
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
if (block) {
|
|
1014
|
-
log(`handoff: "${last.thread.topic}" summary=${last.conclusion ? 'yes' : 'no'}`);
|
|
1015
|
-
trace({
|
|
1076
|
+
const shot = readShot(sessionId);
|
|
1077
|
+
if (shot.block) {
|
|
1078
|
+
handedOff.add(sessionId);
|
|
1079
|
+
logger(`handoff: "${shot.topic}" summary=${shot.summary ? 'yes' : 'no'}`);
|
|
1080
|
+
tracer({
|
|
1016
1081
|
event: 'handoff',
|
|
1017
|
-
thread:
|
|
1018
|
-
theme:
|
|
1019
|
-
hasSummary:
|
|
1082
|
+
thread: shot.thread,
|
|
1083
|
+
theme: shot.topic,
|
|
1084
|
+
hasSummary: shot.summary,
|
|
1020
1085
|
});
|
|
1021
1086
|
}
|
|
1022
|
-
return block;
|
|
1087
|
+
return shot.block;
|
|
1023
1088
|
} catch (error) {
|
|
1024
|
-
|
|
1089
|
+
logger(`handoff failed (will retry next turn): ${error instanceof Error ? error.message : String(error)}`);
|
|
1025
1090
|
return '';
|
|
1026
1091
|
}
|
|
1027
|
-
}
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export function createPreStepHandler(deps) {
|
|
1096
|
+
/** sessionId -> last turn that received an injection */
|
|
1097
|
+
const injectedTurn = new Map();
|
|
1098
|
+
|
|
1099
|
+
// "Where were we": one non-empty block per session, retried until it ships.
|
|
1100
|
+
// The reader goes through ensureSoul() so the live store is read at call time.
|
|
1101
|
+
const takeHandoff = createHandoffTaker(
|
|
1102
|
+
(sessionId) => {
|
|
1103
|
+
const last = ensureSoul().lastClosedThread();
|
|
1104
|
+
return {
|
|
1105
|
+
block: renderHandoff(last, sessionId),
|
|
1106
|
+
thread: last?.thread?.id,
|
|
1107
|
+
topic: last?.thread?.topic,
|
|
1108
|
+
summary: Boolean(last?.conclusion),
|
|
1109
|
+
};
|
|
1110
|
+
},
|
|
1111
|
+
log,
|
|
1112
|
+
trace,
|
|
1113
|
+
);
|
|
1028
1114
|
|
|
1029
1115
|
return async (payload, next) => {
|
|
1030
1116
|
const decision = await next();
|
|
@@ -1147,7 +1233,10 @@ export function createPreStepHandler(deps) {
|
|
|
1147
1233
|
// previous session: one session can hold several topics, and a topic can
|
|
1148
1234
|
// span sessions. Injected once per session, when it is a different one.
|
|
1149
1235
|
const handoff = takeHandoff(sessionId);
|
|
1150
|
-
|
|
1236
|
+
// 0026: when this session has already grown past the point where a new one is
|
|
1237
|
+
// cheaper, say so once per level — the reply is the only channel the user reads.
|
|
1238
|
+
const switchHint = sessionLedger.due(sessionId);
|
|
1239
|
+
const block = [handoff, switchHint, related].filter(Boolean).join('\n\n');
|
|
1151
1240
|
injectedTurn.set(sessionId, turn);
|
|
1152
1241
|
if (injectedTurn.size > 16) {
|
|
1153
1242
|
// Keep the map bounded: drop the oldest entry, not the newest.
|
|
@@ -1168,6 +1257,10 @@ export function createPreStepHandler(deps) {
|
|
|
1168
1257
|
const idPart = used.length > 0 ? ` ids=[${used.join(',')}]` : '';
|
|
1169
1258
|
log(`prestep: inject turn=${turn} chars=${block.length}${idPart} handoff=${handoff ? 'yes' : 'no'}`);
|
|
1170
1259
|
trace({ event: 'inject', session: sessionId, turn, chars: block.length, ids: used, handoff: Boolean(handoff) });
|
|
1260
|
+
if (switchHint) {
|
|
1261
|
+
log(`switch-session: remind turn=${turn}`);
|
|
1262
|
+
trace({ event: 'switch_session', session: sessionId, turn });
|
|
1263
|
+
}
|
|
1171
1264
|
return { ...decision, messages: [...decision.messages, note] };
|
|
1172
1265
|
} catch (error) {
|
|
1173
1266
|
log(`prestep: skipped after error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1424,8 +1517,10 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
|
|
|
1424
1517
|
// Provenance is observable (ADR 0035): the fact count alone cannot tell whether
|
|
1425
1518
|
// anything came from my side of the exchange.
|
|
1426
1519
|
const inferred = result.facts.filter((fact) => fact.source === 'inferred').length;
|
|
1520
|
+
// "done", not "ok": the caller decides the queue outcome next (rules:model-failed
|
|
1521
|
+
// is NOT ok), and the old prefix read as success in every log review.
|
|
1427
1522
|
log(
|
|
1428
|
-
`extract
|
|
1523
|
+
`extract done: by=${draft.by} reason=${draft.reason ?? '-'} entities=${result.entities.length} facts=${result.facts.length}` +
|
|
1429
1524
|
` gaps=${result.gaps.length} skipped=${result.skipped.length} draftSkipped=${(draft.skipped ?? []).length}` +
|
|
1430
1525
|
` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
|
|
1431
1526
|
` inferred=${inferred}` +
|
|
@@ -2523,7 +2618,12 @@ export function apply(ctx) {
|
|
|
2523
2618
|
// reaches the threshold also gets one line in plugin.log. Attached before the
|
|
2524
2619
|
// event listener so no turn can be missed.
|
|
2525
2620
|
turnCostTracker = createTurnCostTracker({
|
|
2526
|
-
onReport: (record) =>
|
|
2621
|
+
onReport: (record) => {
|
|
2622
|
+
trace(record);
|
|
2623
|
+
// 0026 reminder: the same record that makes a turn visible also answers
|
|
2624
|
+
// "is this session too big to keep going".
|
|
2625
|
+
sessionLedger.note(record);
|
|
2626
|
+
},
|
|
2527
2627
|
onAlert: (line) => log(line),
|
|
2528
2628
|
});
|
|
2529
2629
|
|