dsh-plugin-soulbridge 0.0.121 → 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.
Files changed (2) hide show
  1. package/index.js +84 -40
  2. package/package.json +1 -1
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 reminder the model relays: the reply is the only channel the user reads. */
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
- `请提醒用户:新开一个会话更省——先把进度写进 docs/state.md 的接手须知,` +
265
+ `请提醒用户:优先 /compact 续命;若已反复压缩或明显变慢,再新开会话——` +
266
+ `新开前先把进度写进 docs/state.md 的接手须知,` +
252
267
  `再到新会话用「读 docs/state.md 的接手须知」开头。` +
253
- `</switch-session>`
268
+ `</proactive>`
254
269
  );
255
270
  }
256
271
 
257
272
  /**
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?
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
- * 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.
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({ thresholds = SWITCH_SESSION } = {}) {
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 reminder block when a new level is crossed; '' otherwise. */
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 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);
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
- // 0026: when this session has already grown past the point where a new one is
1245
- // cheaper, say so once per level — the reply is the only channel the user reads.
1246
- const switchHint = sessionLedger.due(sessionId);
1247
- const block = [handoff, switchHint, related].filter(Boolean).join('\n\n');
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 (switchHint) {
1269
- log(`switch-session: remind turn=${turn}`);
1270
- trace({ event: 'switch_session', session: sessionId, turn });
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: a segment (0028), or a single message when there is no segment.
1628
- *
1629
- * `unit` carries the prompt-numbered turns; the attribution the model returns is
1630
- * a product of this call, never an input to it (决定 3). `turnId`/`turnNo` are the
1631
- * segment HEAD — the continuity point before the unit — and the fallback source
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 = null) {
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?.turns?.length ? unit.turns.map((entry) => entry.userText) : [text];
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 — extraction is per turn, so without it the
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?.turns ?? null;
1666
- const turnIds = turns ? Object.fromEntries(turns.map((entry) => [entry.turnNo, entry.id])) : undefined;
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,7 +1717,7 @@ 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: turns ?? undefined,
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
@@ -1681,12 +1725,12 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
1681
1725
  // fanned over per turn (0028 ③): every turn of the segment gets attached, a
1682
1726
  // turn no span covers chains to the previous one, and the continue/new/
1683
1727
  // fallback chain inside applyThread is untouched (ADR 0034).
1684
- const spanTurns = (turns ?? [{ turnNo: 1, id: turnId, userText: text }]).map((entry) => ({
1728
+ const spanTurns = turns.map((entry) => ({
1685
1729
  promptNo: entry.turnNo,
1686
1730
  turnNo: entry.hostTurnNo ?? turnNo,
1687
1731
  turnId: entry.id,
1688
1732
  at: entry.startedAt ?? Date.now(),
1689
- fallbackTopic: firstSentence(entry.userText ?? text),
1733
+ fallbackTopic: firstSentence(entry.userText),
1690
1734
  }));
1691
1735
  const applied = soul.applyThreadSpans(draft.spans, {
1692
1736
  sessionId: turnRow?.sessionId ?? 'unknown',
@@ -1712,7 +1756,7 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null)
1712
1756
  ` gaps=${result.gaps.length} skipped=${result.skipped.length} draftSkipped=${(draft.skipped ?? []).length}` +
1713
1757
  ` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
1714
1758
  ` inferred=${inferred}` +
1715
- ` unit=${turns ? `${turns.length}turns` : '1turn'}` +
1759
+ ` unit=${turns.length}turns` +
1716
1760
  ` ms=${ms} turn=${turnNo} origin=${origin}` +
1717
1761
  ` thread=${threadIds.join(',')}` +
1718
1762
  `${createdNew > 0 ? (threadIds.length === 1 ? '(new)' : `(${createdNew} new)`) : ''}` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-soulbridge",
3
- "version": "0.0.121",
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",