dsh-plugin-soulbridge 0.0.120 → 0.0.122
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 +123 -47
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -242,27 +242,65 @@ export function createTurnCostTracker({ threshold = TURN_REQUEST_ALERT, maxTurns
|
|
|
242
242
|
*/
|
|
243
243
|
const SWITCH_SESSION = { contextPerRequest: 300_000, turns: 100 };
|
|
244
244
|
|
|
245
|
-
/** The
|
|
245
|
+
/** The 0018 compact line: the same signal one notch earlier — compact before switching. */
|
|
246
|
+
const CONTEXT_COMPACT = { contextPerRequest: 200_000 };
|
|
247
|
+
|
|
248
|
+
/** The advice the model relays: the reply is the only channel the user reads. */
|
|
249
|
+
export function renderContextCompact(stats, perRequest) {
|
|
250
|
+
const thousands = Math.round(perRequest / 1000);
|
|
251
|
+
return (
|
|
252
|
+
`<proactive id="context-compact" context-per-request="${Math.round(perRequest)}">` +
|
|
253
|
+
`上下文已经到每请求约 ${thousands}k token。请在合适处告诉用户一句:现在适合 /compact 续命——` +
|
|
254
|
+
`他的习惯是留在本会话压缩、不换新会话;若正在收口,压缩前先把进度写进 docs/state.md。` +
|
|
255
|
+
`提这一次即可,不要每轮重复。` +
|
|
256
|
+
`</proactive>`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
export function renderSwitchSession(stats, perRequest) {
|
|
247
261
|
const thousands = Math.round(perRequest / 1000);
|
|
248
262
|
return (
|
|
249
|
-
`<switch-session turns="${stats.turns}" context-per-request="${Math.round(perRequest)}">` +
|
|
263
|
+
`<proactive id="switch-session" turns="${stats.turns}" context-per-request="${Math.round(perRequest)}">` +
|
|
250
264
|
`这个会话已经很大(每请求约 ${thousands}k token 上下文,共 ${stats.turns} 轮)。` +
|
|
251
|
-
|
|
265
|
+
`请提醒用户:优先 /compact 续命;若已反复压缩或明显变慢,再新开会话——` +
|
|
266
|
+
`新开前先把进度写进 docs/state.md 的接手须知,` +
|
|
252
267
|
`再到新会话用「读 docs/state.md 的接手须知」开头。` +
|
|
253
|
-
`</
|
|
268
|
+
`</proactive>`
|
|
254
269
|
);
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
273
|
+
* 0018 proactive triggers — declarative rows. A new habit is a new row; needing a new
|
|
274
|
+
* code path means the framework failed (0018 §0 acceptance line). Each row carries an
|
|
275
|
+
* id, the action kind (four choices, default silent — every row here is asking), a
|
|
276
|
+
* level over the session ledger, and the block to relay. v1 cost-gate = level crossing
|
|
277
|
+
* + once per level; saying it every turn is wallpaper.
|
|
278
|
+
*/
|
|
279
|
+
export const PROACTIVE_TRIGGERS = [
|
|
280
|
+
{
|
|
281
|
+
id: 'context-compact',
|
|
282
|
+
action: 'asking',
|
|
283
|
+
level: (stats, perRequest) => Math.floor(perRequest / CONTEXT_COMPACT.contextPerRequest),
|
|
284
|
+
render: (stats, perRequest) => renderContextCompact(stats, perRequest),
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 'switch-session',
|
|
288
|
+
action: 'asking',
|
|
289
|
+
level: (stats, perRequest) =>
|
|
290
|
+
Math.floor(Math.max(perRequest / SWITCH_SESSION.contextPerRequest, stats.turns / SWITCH_SESSION.turns)),
|
|
291
|
+
render: (stats, perRequest) => renderSwitchSession(stats, perRequest),
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Per-session cost ledger. It answers one question: has this session crossed a line
|
|
297
|
+
* where advice is worth saying, and has the user been told yet?
|
|
260
298
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* worth saying again; saying it every turn is wallpaper.
|
|
299
|
+
* Rows fire in registry order, each once per level: 200k suggests compact (0018 first
|
|
300
|
+
* slice), 300k/100 turns still says switch — compact first, switching is the fallback.
|
|
301
|
+
* Crossing again by a doubling is worth saying again; saying it every turn is wallpaper.
|
|
264
302
|
*/
|
|
265
|
-
export function createSessionLedger({
|
|
303
|
+
export function createSessionLedger({ triggers = PROACTIVE_TRIGGERS } = {}) {
|
|
266
304
|
const cost = new Map();
|
|
267
305
|
const reminded = new Map();
|
|
268
306
|
return {
|
|
@@ -278,18 +316,24 @@ export function createSessionLedger({ thresholds = SWITCH_SESSION } = {}) {
|
|
|
278
316
|
// Bounded like the tracker: a long-lived host must not accumulate sessions forever.
|
|
279
317
|
if (cost.size > 16) cost.delete(cost.keys().next().value);
|
|
280
318
|
},
|
|
281
|
-
/** The
|
|
319
|
+
/** The advice when a row crosses a new level; null otherwise. */
|
|
282
320
|
due(sessionId) {
|
|
283
321
|
const id = String(sessionId ?? 'default');
|
|
284
322
|
const stats = cost.get(id);
|
|
285
|
-
if (!stats || stats.requests === 0) return
|
|
323
|
+
if (!stats || stats.requests === 0) return null;
|
|
286
324
|
const perRequest = stats.prompt / stats.requests;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
325
|
+
for (const trigger of triggers) {
|
|
326
|
+
const level = trigger.level(stats, perRequest);
|
|
327
|
+
if (level < 1 || level <= (reminded.get(`${id}:${trigger.id}`) ?? 0)) continue;
|
|
328
|
+
reminded.set(`${id}:${trigger.id}`, level);
|
|
329
|
+
return {
|
|
330
|
+
id: trigger.id,
|
|
331
|
+
action: trigger.action,
|
|
332
|
+
block: trigger.render(stats, perRequest),
|
|
333
|
+
trigger: Math.round(perRequest),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
293
337
|
},
|
|
294
338
|
};
|
|
295
339
|
}
|
|
@@ -1241,10 +1285,10 @@ export function createPreStepHandler(deps) {
|
|
|
1241
1285
|
// previous session: one session can hold several topics, and a topic can
|
|
1242
1286
|
// span sessions. Injected once per session, when it is a different one.
|
|
1243
1287
|
const handoff = takeHandoff(sessionId);
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
const
|
|
1247
|
-
const block = [handoff,
|
|
1288
|
+
// 0018: proactive advice — declarative rows fire once per level; the reply is
|
|
1289
|
+
// the only channel the user reads.
|
|
1290
|
+
const proactive = sessionLedger.due(sessionId);
|
|
1291
|
+
const block = [handoff, proactive?.block ?? '', related].filter(Boolean).join('\n\n');
|
|
1248
1292
|
injectedTurn.set(sessionId, turn);
|
|
1249
1293
|
if (injectedTurn.size > 16) {
|
|
1250
1294
|
// Keep the map bounded: drop the oldest entry, not the newest.
|
|
@@ -1265,9 +1309,9 @@ export function createPreStepHandler(deps) {
|
|
|
1265
1309
|
const idPart = used.length > 0 ? ` ids=[${used.join(',')}]` : '';
|
|
1266
1310
|
log(`prestep: inject turn=${turn} chars=${block.length}${idPart} handoff=${handoff ? 'yes' : 'no'}`);
|
|
1267
1311
|
trace({ event: 'inject', session: sessionId, turn, chars: block.length, ids: used, handoff: Boolean(handoff) });
|
|
1268
|
-
if (
|
|
1269
|
-
log(`
|
|
1270
|
-
trace({ event: '
|
|
1312
|
+
if (proactive) {
|
|
1313
|
+
log(`proactive: remind id=${proactive.id} turn=${turn} trigger=${proactive.trigger}`);
|
|
1314
|
+
trace({ event: 'proactive', id: proactive.id, action: proactive.action, session: sessionId, turn, trigger: proactive.trigger });
|
|
1271
1315
|
}
|
|
1272
1316
|
return { ...decision, messages: [...decision.messages, note] };
|
|
1273
1317
|
} catch (error) {
|
|
@@ -1624,19 +1668,19 @@ function hasSubstance(text) {
|
|
|
1624
1668
|
}
|
|
1625
1669
|
|
|
1626
1670
|
/**
|
|
1627
|
-
* Extract one unit:
|
|
1628
|
-
*
|
|
1629
|
-
* `unit` carries the prompt-numbered turns; the
|
|
1630
|
-
* a product of this call, never an input to it
|
|
1631
|
-
* segment HEAD — the continuity point before
|
|
1632
|
-
* for facts the model did not number.
|
|
1671
|
+
* Extract one unit: the segment a departure flushed (0028) — required, there is
|
|
1672
|
+
* no single-message mode anymore (retirements extract-per-turn): an unflushed
|
|
1673
|
+
* turn waits for its departure. `unit` carries the prompt-numbered turns; the
|
|
1674
|
+
* attribution the model returns is a product of this call, never an input to it
|
|
1675
|
+
* (决定 3). `turnId`/`turnNo` are the segment HEAD — the continuity point before
|
|
1676
|
+
* the unit — and the fallback source for facts the model did not number.
|
|
1633
1677
|
*/
|
|
1634
|
-
async function runExtraction(text, turnId, turnNo, origin = 'live', unit
|
|
1678
|
+
async function runExtraction(text, turnId, turnNo, origin = 'live', unit) {
|
|
1635
1679
|
const config = readConfig();
|
|
1636
1680
|
if (config.extract === 'off') return 'skipped-off';
|
|
1637
1681
|
// The unit decides, not its last message: a segment whose final turn is "好的"
|
|
1638
1682
|
// still holds every turn above it, and taking the unit or none of it is rule 17.
|
|
1639
|
-
const unitTexts = unit
|
|
1683
|
+
const unitTexts = unit.turns.map((entry) => entry.userText);
|
|
1640
1684
|
if (typeof text !== 'string' || !unitTexts.some((entry) => typeof entry === 'string' && hasSubstance(entry))) {
|
|
1641
1685
|
log(
|
|
1642
1686
|
`extract skipped: no substance (chars=${unitTexts.reduce((n, entry) => n + (typeof entry === 'string' ? entry.length : 0), 0)})` +
|
|
@@ -1650,7 +1694,7 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
|
|
|
1650
1694
|
const soul = ensureSoul();
|
|
1651
1695
|
const turnRow = soul.turn(turnId);
|
|
1652
1696
|
// The context carries both halves of continuity (ADR 0034): the candidates' last
|
|
1653
|
-
// lines, and the previous user line —
|
|
1697
|
+
// lines, and the previous user line — the unit is a segment, so without them the
|
|
1654
1698
|
// model cannot tell "继续" from a new subject.
|
|
1655
1699
|
const context = soul.extractionContext(20, { turnNo, sessionId: turnRow?.sessionId });
|
|
1656
1700
|
// "The previous turn belonged to this thread" is the cheapest continuity hint
|
|
@@ -1662,8 +1706,8 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
|
|
|
1662
1706
|
// 0028: the unit is the segment a departure flushed. The turns array is
|
|
1663
1707
|
// numbered the way the prompt numbers them (1..n inside the unit), and each
|
|
1664
1708
|
// fact may name its own turn — turnIds maps that number back to the real turn.
|
|
1665
|
-
const turns = unit
|
|
1666
|
-
const turnIds =
|
|
1709
|
+
const turns = unit.turns;
|
|
1710
|
+
const turnIds = Object.fromEntries(turns.map((entry) => [entry.turnNo, entry.id]));
|
|
1667
1711
|
const draft = await soul.extract(text, {
|
|
1668
1712
|
llm: useLlm ? injectedLlm : undefined,
|
|
1669
1713
|
mode: useLlm ? 'auto' : 'rules',
|
|
@@ -1673,19 +1717,34 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
|
|
|
1673
1717
|
// The exchange, not the message (ADR 0035): my reply is where promises and
|
|
1674
1718
|
// decisions live, and it is the only way "我的承诺" can ever be extracted.
|
|
1675
1719
|
assistantText: turnRow?.assistantText ?? undefined,
|
|
1676
|
-
turns
|
|
1720
|
+
turns,
|
|
1677
1721
|
});
|
|
1678
1722
|
const result = soul.commitDrafts(draft, { turnId, turnIds });
|
|
1679
1723
|
// Attribution is written even when nothing was extracted: a turn that says
|
|
1680
|
-
// "无" (nothing worth remembering) can still be part of a thread.
|
|
1681
|
-
|
|
1724
|
+
// "无" (nothing worth remembering) can still be part of a thread. The unit is
|
|
1725
|
+
// fanned over per turn (0028 ③): every turn of the segment gets attached, a
|
|
1726
|
+
// turn no span covers chains to the previous one, and the continue/new/
|
|
1727
|
+
// fallback chain inside applyThread is untouched (ADR 0034).
|
|
1728
|
+
const spanTurns = turns.map((entry) => ({
|
|
1729
|
+
promptNo: entry.turnNo,
|
|
1730
|
+
turnNo: entry.hostTurnNo ?? turnNo,
|
|
1731
|
+
turnId: entry.id,
|
|
1732
|
+
at: entry.startedAt ?? Date.now(),
|
|
1733
|
+
fallbackTopic: firstSentence(entry.userText),
|
|
1734
|
+
}));
|
|
1735
|
+
const applied = soul.applyThreadSpans(draft.spans, {
|
|
1682
1736
|
sessionId: turnRow?.sessionId ?? 'unknown',
|
|
1683
|
-
|
|
1684
|
-
turnId,
|
|
1685
|
-
at: Date.now(),
|
|
1686
|
-
fallbackTopic: firstSentence(text),
|
|
1737
|
+
turns: spanTurns,
|
|
1687
1738
|
previousThreadId: context.previousThreadId,
|
|
1688
1739
|
});
|
|
1740
|
+
const attribution = applied[0];
|
|
1741
|
+
// One thread keeps the old line's thread part byte for byte (`thread=th_xxx(new)`);
|
|
1742
|
+
// a split segment lists every thread it produced, and `join=` is the unit's
|
|
1743
|
+
// per-turn reasons joined with `+` (one value only when they agree) — the head
|
|
1744
|
+
// alone no longer describes what the rest of the segment did.
|
|
1745
|
+
const threadIds = [...new Set(applied.map((entry) => entry.threadId))];
|
|
1746
|
+
const createdNew = applied.filter((entry) => entry.created).length;
|
|
1747
|
+
const joins = [...new Set(applied.map((entry) => entry.joinReason))].join('+');
|
|
1689
1748
|
const ms = Date.now() - started;
|
|
1690
1749
|
// Provenance is observable (ADR 0035): the fact count alone cannot tell whether
|
|
1691
1750
|
// anything came from my side of the exchange.
|
|
@@ -1697,11 +1756,12 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
|
|
|
1697
1756
|
` gaps=${result.gaps.length} skipped=${result.skipped.length} draftSkipped=${(draft.skipped ?? []).length}` +
|
|
1698
1757
|
` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
|
|
1699
1758
|
` inferred=${inferred}` +
|
|
1700
|
-
` unit=${turns
|
|
1759
|
+
` unit=${turns.length}turns` +
|
|
1701
1760
|
` ms=${ms} turn=${turnNo} origin=${origin}` +
|
|
1702
|
-
` thread=${
|
|
1703
|
-
|
|
1704
|
-
|
|
1761
|
+
` thread=${threadIds.join(',')}` +
|
|
1762
|
+
`${createdNew > 0 ? (threadIds.length === 1 ? '(new)' : `(${createdNew} new)`) : ''}` +
|
|
1763
|
+
` join=${joins} threadLine=${draft.threadLine ?? 'rules'}` +
|
|
1764
|
+
`${applied.some((entry) => entry.topicUpdated) ? ' topic=updated' : ''}`,
|
|
1705
1765
|
);
|
|
1706
1766
|
for (const skipped of result.skipped.slice(0, 3)) {
|
|
1707
1767
|
log(` extract skipped: ${skipped.what} (${skipped.reason})`);
|
|
@@ -1732,6 +1792,18 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
|
|
|
1732
1792
|
threadTopic: attribution.topic,
|
|
1733
1793
|
threadLine: draft.threadLine ?? null,
|
|
1734
1794
|
topicUpdated: attribution.topicUpdated,
|
|
1795
|
+
// Only when the segment actually split: one thread is the old shape, and the
|
|
1796
|
+
// per-turn runs are what makes a wrong split diagnosable later (0028 ③).
|
|
1797
|
+
...(applied.length > 1
|
|
1798
|
+
? {
|
|
1799
|
+
threadSpans: applied.map((entry, index) => ({
|
|
1800
|
+
span: spanTurns[index].promptNo,
|
|
1801
|
+
thread: entry.threadId,
|
|
1802
|
+
join: entry.joinReason,
|
|
1803
|
+
created: entry.created,
|
|
1804
|
+
})),
|
|
1805
|
+
}
|
|
1806
|
+
: {}),
|
|
1735
1807
|
origin,
|
|
1736
1808
|
joinReason: attribution.joinReason,
|
|
1737
1809
|
ms,
|
|
@@ -1851,7 +1923,11 @@ export async function drainExtractionQueue(options = {}) {
|
|
|
1851
1923
|
// turn, so a fact's 轮次 lands on source_turn_id.
|
|
1852
1924
|
const turns = taken.map((entry, index) => ({
|
|
1853
1925
|
id: entry.item.turnId,
|
|
1926
|
+
// Prompt numbering (1..n, what the model's spans match) and the host's real
|
|
1927
|
+
// turn number (threads count turns that jump by 10–30) are different numbers;
|
|
1928
|
+
// both ride along so attribution never has to guess which is which.
|
|
1854
1929
|
turnNo: index + 1,
|
|
1930
|
+
hostTurnNo: entry.row.turn,
|
|
1855
1931
|
userText: entry.row.userText,
|
|
1856
1932
|
assistantText: entry.row.assistantText,
|
|
1857
1933
|
startedAt: entry.row.at,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-soulbridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.122",
|
|
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.
|
|
45
|
+
"soulbridge": ">=0.0.117 <0.1.0"
|
|
46
46
|
}
|
|
47
47
|
}
|