dsh-plugin-soulbridge 0.0.110 → 0.0.112

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 +49 -8
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -509,6 +509,11 @@ export function buildFactHooks(text, options = {}) {
509
509
  lines.push(`- ${marker}[${fact.id}] ${escapeBlockText(fact.statement)}${when ? `(${escapeBlockText(when)})` : ''}`);
510
510
  used.push(fact.id);
511
511
  }
512
+ // The open questions about this person are what make the block actionable: without
513
+ // them the model sees history but cannot ask "how is she now".
514
+ for (const gap of ensureSoul().openGapsAbout(entity.id).slice(0, 2)) {
515
+ lines.push(`- 未闭合:${escapeBlockText(gap.topic)}`);
516
+ }
512
517
  if (lines.length > 0) {
513
518
  parts.push(`<about name="${escapeAttr(entity.name)}">\n${lines.join('\n')}\n</about>`);
514
519
  }
@@ -1093,6 +1098,17 @@ function bumpExtractAttempt(turnId) {
1093
1098
  );
1094
1099
  }
1095
1100
 
1101
+ /**
1102
+ * After a thread was extracted, every queued turn of that thread is already covered.
1103
+ * Leaving them would extract the same topic again, turn by turn (0025).
1104
+ */
1105
+ function removeExtractItemsOfThread(threadId, turnIds) {
1106
+ if (!threadId) return;
1107
+ writeExtractQueue(
1108
+ readExtractQueue().filter((item) => !turnIds.includes(item.turnId)),
1109
+ );
1110
+ }
1111
+
1096
1112
  /**
1097
1113
  * Add one turn to the extraction queue.
1098
1114
  *
@@ -1126,7 +1142,7 @@ function hasSubstance(text) {
1126
1142
  return !/^(好的?|嗯+|哦+|可以|行|ok|okay|收到|明白|继续|好)[。!!.\s]*$/i.test(trimmed);
1127
1143
  }
1128
1144
 
1129
- async function runExtraction(text, turnId, turnNo, origin = 'live') {
1145
+ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = null) {
1130
1146
  const config = readConfig();
1131
1147
  if (config.extract === 'off') return 'skipped-off';
1132
1148
  if (typeof text !== 'string' || !hasSubstance(text)) {
@@ -1148,6 +1164,10 @@ async function runExtraction(text, turnId, turnNo, origin = 'live') {
1148
1164
  const previousThreadId = soul.threadBefore(turnRow.sessionId, turnNo);
1149
1165
  if (previousThreadId) context.previousThreadId = previousThreadId;
1150
1166
  }
1167
+ // 0025: the unit is the whole topic when the turn belongs to one. The turns array is
1168
+ // numbered the way the prompt numbers them, and each fact may name its own turn.
1169
+ const turns = thread?.turns ?? null;
1170
+ const turnIds = turns ? Object.fromEntries(turns.map((entry) => [entry.turnNo, entry.id])) : undefined;
1151
1171
  const draft = await soul.extract(text, {
1152
1172
  llm: useLlm ? injectedLlm : undefined,
1153
1173
  mode: useLlm ? 'auto' : 'rules',
@@ -1157,8 +1177,9 @@ async function runExtraction(text, turnId, turnNo, origin = 'live') {
1157
1177
  // The exchange, not the message (ADR 0035): my reply is where promises and
1158
1178
  // decisions live, and it is the only way "我的承诺" can ever be extracted.
1159
1179
  assistantText: turnRow?.assistantText ?? undefined,
1180
+ turns: turns ?? undefined,
1160
1181
  });
1161
- const result = soul.commitDrafts(draft, { turnId });
1182
+ const result = soul.commitDrafts(draft, { turnId, turnIds });
1162
1183
  // Attribution is written even when nothing was extracted: a turn that says
1163
1184
  // "无" (nothing worth remembering) can still be part of a thread.
1164
1185
  const attribution = soul.applyThread(draft.thread, {
@@ -1178,6 +1199,7 @@ async function runExtraction(text, turnId, turnNo, origin = 'live') {
1178
1199
  ` gaps=${result.gaps.length} skipped=${result.skipped.length}` +
1179
1200
  ` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
1180
1201
  ` inferred=${inferred}` +
1202
+ ` unit=${turns ? `${turns.length}turns` : '1turn'} threadId=${thread?.id ?? '-'}` +
1181
1203
  ` ms=${ms} turn=${turnNo} origin=${origin}` +
1182
1204
  ` thread=${attribution.threadId}${attribution.created ? '(new)' : ''}` +
1183
1205
  ` join=${attribution.joinReason} threadLine=${draft.threadLine ?? 'rules'}` +
@@ -1271,9 +1293,21 @@ export async function drainExtractionQueue() {
1271
1293
  try {
1272
1294
  const item = pickExtractionItem(queue);
1273
1295
  let text = '';
1296
+ let thread = null;
1274
1297
  try {
1275
- const turn = ensureSoul().turn(item.turnId);
1298
+ const soul = ensureSoul();
1299
+ const turn = soul.turn(item.turnId);
1276
1300
  text = turn?.userText ?? '';
1301
+ // A turn is a trigger, the topic is the unit (0025). When the turn is already in a
1302
+ // thread, extract that thread's turns instead of this message alone.
1303
+ const threadId = soul.turnThreadId(item.turnId);
1304
+ if (threadId) {
1305
+ const turns = soul.threadTurns(threadId);
1306
+ if (turns.length > 0) {
1307
+ thread = { id: threadId, turns };
1308
+ text = turns[turns.length - 1].userText;
1309
+ }
1310
+ }
1277
1311
  } catch {
1278
1312
  text = '';
1279
1313
  }
@@ -1283,14 +1317,21 @@ export async function drainExtractionQueue() {
1283
1317
  return;
1284
1318
  }
1285
1319
 
1286
- const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live');
1320
+ const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live', thread);
1287
1321
  const ok =
1288
1322
  outcome === 'ok' || outcome === 'skipped-off' || outcome === 'skipped-empty' || outcome === 'rules-mode';
1289
1323
  if (ok) {
1290
- removeExtractItem(item.turnId);
1291
- // Promotion is decided after the facts exist: a corrected turn's lessons enter
1292
- // the classification queue here, which is the only live producer it has.
1293
- feedClassifyFromTurn(item.turnId);
1324
+ if (thread) {
1325
+ removeExtractItemsOfThread(thread.id, thread.turns.map((entry) => entry.id));
1326
+ // Promotion is decided after the facts exist; a whole thread can hold several
1327
+ // corrected turns, so every turn is offered.
1328
+ for (const entry of thread.turns) feedClassifyFromTurn(entry.id);
1329
+ } else {
1330
+ removeExtractItem(item.turnId);
1331
+ // Promotion is decided after the facts exist: a corrected turn's lessons enter
1332
+ // the classification queue here, which is the only live producer it has.
1333
+ feedClassifyFromTurn(item.turnId);
1334
+ }
1294
1335
  trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome });
1295
1336
  return true;
1296
1337
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-soulbridge",
3
- "version": "0.0.110",
3
+ "version": "0.0.112",
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.107 <0.1.0"
45
+ "soulbridge": ">=0.0.112 <0.1.0"
46
46
  }
47
47
  }