dsh-plugin-soulbridge 0.0.118 → 0.0.120

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 +367 -107
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1053,47 +1053,75 @@ export async function runStageChain(stages) {
1053
1053
  return false;
1054
1054
  }
1055
1055
 
1056
- export function createPreStepHandler(deps) {
1057
- /** sessionId -> last turn that received an injection */
1058
- const injectedTurn = new Map();
1059
- /** Sessions that already got the handoff block once. */
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. */
1060
1072
  const handedOff = new Set();
1061
-
1062
- /**
1063
- * The "where were we" block: the last topic that finished.
1064
- *
1065
- * Deliberately once per session and only when that topic belongs to a
1066
- * different session — repeating it every turn would make it wallpaper, and
1067
- * re-reading the previous session's own topic is not a handoff.
1068
- */
1069
- function takeHandoff(sessionId) {
1073
+ return (sessionId) => {
1070
1074
  if (handedOff.has(sessionId)) return '';
1071
1075
  try {
1072
- const last = ensureSoul().lastClosedThread();
1073
- const block = renderHandoff(last, sessionId);
1074
- // Only mark it once the read succeeded: a transient failure on the first turn
1075
- // used to exclude the session from handoff forever.
1076
- handedOff.add(sessionId);
1077
- if (block) {
1078
- log(`handoff: "${last.thread.topic}" summary=${last.conclusion ? 'yes' : 'no'}`);
1079
- 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({
1080
1081
  event: 'handoff',
1081
- thread: last.thread.id,
1082
- theme: last.thread.topic,
1083
- hasSummary: Boolean(last.conclusion),
1082
+ thread: shot.thread,
1083
+ theme: shot.topic,
1084
+ hasSummary: shot.summary,
1084
1085
  });
1085
1086
  }
1086
- return block;
1087
+ return shot.block;
1087
1088
  } catch (error) {
1088
- log(`handoff failed (will retry next turn): ${error instanceof Error ? error.message : String(error)}`);
1089
+ logger(`handoff failed (will retry next turn): ${error instanceof Error ? error.message : String(error)}`);
1089
1090
  return '';
1090
1091
  }
1091
- }
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
+ );
1092
1114
 
1093
1115
  return async (payload, next) => {
1094
1116
  const decision = await next();
1095
1117
  try {
1096
1118
  if (decision?.kind !== 'enter') return decision;
1119
+ // Hoisted before the chain starts: its stage closures run synchronously inside
1120
+ // runStageChain, so reading these afterwards would hit the temporal dead zone.
1121
+ // `sessionId` is what the departure checklist compares buffered turns against
1122
+ // (0028); `archivedSessions` is the same host set closeIdleThreads uses below.
1123
+ const sessionId = payload?.agent?.id ?? payload?.agent?.session?.id ?? 'default';
1124
+ const archivedSessions = deps.archivedSessions?.() ?? [];
1097
1125
  // We are inside a live turn now, which is the only place the host model is
1098
1126
  // callable. Stages run in priority order and a stage that skipped its work
1099
1127
  // must not claim the call — that is how the classifier starved for an hour.
@@ -1119,9 +1147,12 @@ export function createPreStepHandler(deps) {
1119
1147
  // Live conversation before everything; understanding before the backfill.
1120
1148
  // Fixed priority (extract always first) plus a long backfill is a starvation
1121
1149
  // bug, not a preference: 29 closed threads waited while the backfill drained.
1122
- if (extractRunsFirst(extractQueue, consolidationPending)) stages.push(drainExtractionQueue);
1150
+ // The drain carries the departure context: it flushes a segment only when
1151
+ // these say something left the window (0028 决定 2), and waits otherwise.
1152
+ const drainSegment = () => drainExtractionQueue({ sessionId, archivedSessions });
1153
+ if (extractRunsFirst(extractQueue, consolidationPending)) stages.push(drainSegment);
1123
1154
  stages.push(runConsolidationStep);
1124
- stages.push(drainExtractionQueue);
1155
+ stages.push(drainSegment);
1125
1156
  if (profilePending && stageTick % 2 === 0) stages.push(runProfileStep);
1126
1157
  stages.push(runClassifyStep);
1127
1158
  if (profilePending && stageTick % 2 === 1) stages.push(runProfileStep);
@@ -1153,7 +1184,6 @@ export function createPreStepHandler(deps) {
1153
1184
  // Topics the conversation has moved past, closed once per turn. Counting
1154
1185
  // actual turns — the host's turn numbers jump by 10–30 per exchange, so a
1155
1186
  // numeric difference closed a topic one message after it opened.
1156
- const sessionId = payload?.agent?.id ?? payload?.agent?.session?.id ?? 'default';
1157
1187
  try {
1158
1188
  const closeConfig = readConfig();
1159
1189
  const closed = ensureSoul().closeIdleThreads(sessionId, {
@@ -1161,7 +1191,7 @@ export function createPreStepHandler(deps) {
1161
1191
  idleMs: (closeConfig.threadIdleMinutes ?? 30) * 60_000,
1162
1192
  // The host's own archive list: a session the user put away is finished
1163
1193
  // by declaration, so its topics close immediately.
1164
- archivedSessionIds: deps.archivedSessions?.() ?? [],
1194
+ archivedSessionIds: archivedSessions,
1165
1195
  at: Date.now(),
1166
1196
  });
1167
1197
  if (closed.length > 0) {
@@ -1330,6 +1360,17 @@ function flushTurnPair(sessionId) {
1330
1360
  const EXTRACT_QUEUE_FILE = join(HOME, 'pending-extract.json');
1331
1361
  const EXTRACT_MAX_ATTEMPTS = 3;
1332
1362
 
1363
+ /* 0028 决定 2: the departure checklist, declared once (rule 6).
1364
+ * The two size bounds and the age bound are ENGINEERING fallbacks — they flush a
1365
+ * buffer that would otherwise dangle, never a claim that a topic ended. All three
1366
+ * are test inputs to planSegment, not scattered literals. */
1367
+ export const SEGMENT_MAX_TURNS = 40;
1368
+ export const SEGMENT_MAX_CHARS = 60_000;
1369
+ export const SEGMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1370
+ const SEGMENT_STATE_FILE = join(HOME, 'segment-state.json');
1371
+ /** Seals are a marker, not work: the newest N prove "extracted at most once". */
1372
+ const SEGMENT_SEAL_CAP = 100;
1373
+
1333
1374
  /**
1334
1375
  * Read a queue file.
1335
1376
  *
@@ -1400,14 +1441,146 @@ function bumpExtractAttempt(turnId) {
1400
1441
  }
1401
1442
 
1402
1443
  /**
1403
- * After a thread was extracted, every queued turn of that thread is already covered.
1404
- * Leaving them would extract the same topic again, turn by turn (0025).
1444
+ * Remove a whole segment's items by identity, computed from a fresh read.
1445
+ *
1446
+ * A segment is one extraction unit (0028): its turns leave the queue together or
1447
+ * stay together, so the id list is the handle — a thread id no longer is, because
1448
+ * attribution is an output of the extraction, not an input to it.
1449
+ */
1450
+ function removeExtractItems(turnIds) {
1451
+ const ids = new Set(turnIds);
1452
+ writeExtractQueue(readExtractQueue().filter((item) => !ids.has(item.turnId)));
1453
+ }
1454
+
1455
+ /* ------------------------------------------------------------------ *
1456
+ * Segments (0028): what left the context, and what was already flushed
1457
+ * ------------------------------------------------------------------ */
1458
+
1459
+ /**
1460
+ * Read the segment state: the departure marker and the seal records.
1461
+ *
1462
+ * An unreadable file is not an empty one (same rule as the queue files): it is
1463
+ * set aside with its bytes intact, because losing every seal record would let
1464
+ * already-extracted turns be enqueued again with nothing to say so.
1405
1465
  */
1406
- function removeExtractItemsOfThread(threadId, turnIds) {
1407
- if (!threadId) return;
1408
- writeExtractQueue(
1409
- readExtractQueue().filter((item) => !turnIds.includes(item.turnId)),
1410
- );
1466
+ function readSegmentState() {
1467
+ try {
1468
+ const raw = JSON.parse(readFileSync(SEGMENT_STATE_FILE, 'utf8'));
1469
+ return {
1470
+ marker: raw?.marker ?? null,
1471
+ sealed: Array.isArray(raw?.sealed) ? raw.sealed : [],
1472
+ };
1473
+ } catch (error) {
1474
+ if (existsSync(SEGMENT_STATE_FILE)) {
1475
+ log(`segment state unreadable, setting it aside: ${error instanceof Error ? error.message : String(error)}`);
1476
+ try {
1477
+ renameSync(SEGMENT_STATE_FILE, `${SEGMENT_STATE_FILE}.corrupt-${Date.now()}`);
1478
+ } catch {
1479
+ /* the log above is the record */
1480
+ }
1481
+ }
1482
+ return { marker: null, sealed: [] };
1483
+ }
1484
+ }
1485
+
1486
+ function writeSegmentState(state) {
1487
+ try {
1488
+ const sealed = Array.isArray(state.sealed) ? state.sealed : [];
1489
+ const kept = sealed.slice(-SEGMENT_SEAL_CAP);
1490
+ const dropped = sealed.length - kept.length;
1491
+ // A cap drop is visible like every other cap (rule 15/18), even though seals
1492
+ // are markers rather than pending work.
1493
+ if (dropped > 0) log(`segment seals over cap: wrote ${kept.length}, dropped ${dropped}`);
1494
+ writeFileSync(
1495
+ SEGMENT_STATE_FILE,
1496
+ JSON.stringify({ marker: state.marker ?? null, sealed: kept }, null, 2),
1497
+ 'utf8',
1498
+ );
1499
+ } catch (error) {
1500
+ log(`segment state write failed: ${error instanceof Error ? error.message : String(error)}`);
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * The host said "this left the window" (0028 决定 2): set the marker and stop.
1506
+ *
1507
+ * Compression, session switch and archive only ever leave marks — no model call
1508
+ * happens on an event, because the host model is callable inside a step only.
1509
+ * The next drain reads the marker and flushes what it covers.
1510
+ */
1511
+ function setSegmentMarker(marker) {
1512
+ const state = readSegmentState();
1513
+ writeSegmentState({ ...state, marker });
1514
+ log(`segment marker: trigger=${marker.trigger} session=${marker.session ?? '-'} at=${marker.at}`);
1515
+ trace({ event: 'segment-marker', ...marker });
1516
+ }
1517
+
1518
+ /**
1519
+ * Record that a segment was flushed: the marker is spent, the seal list grows.
1520
+ *
1521
+ * The seal is the queryable half of declaration 1 ("a sealed segment is not
1522
+ * extracted twice"): removing the items makes a re-run unlikely, the record makes
1523
+ * the claim checkable — and lets enqueueExtraction refuse a re-queue.
1524
+ */
1525
+ function sealSegment({ trigger, turnIds, turns, chars, outcome, session }) {
1526
+ const state = readSegmentState();
1527
+ writeSegmentState({
1528
+ marker: null,
1529
+ sealed: [
1530
+ ...state.sealed,
1531
+ { at: Date.now(), trigger, session: session ?? null, turns, chars, outcome, turnIds },
1532
+ ],
1533
+ });
1534
+ trace({ event: 'segment-seal', trigger, turns, chars, outcome, session: session ?? null });
1535
+ }
1536
+
1537
+ /**
1538
+ * The departure checklist (0028 决定 2): which buffered turns to flush, and why.
1539
+ *
1540
+ * Pure on purpose — entries, marker, archive set and clock are arguments, so the
1541
+ * trigger table in the brief is a test table (rule 26). Two kinds of answer:
1542
+ *
1543
+ * - a semantic departure: the first CONTIGUOUS run that has left the context
1544
+ * (its session is not the current one, or it predates the compaction marker).
1545
+ * Contiguity keeps the run a stretch of conversation; taking the first run
1546
+ * means a backfill batch appended behind live turns is not stranded behind
1547
+ * them — live turns wait for their own departure, history flushes on its own.
1548
+ * - an engineering fallback over the WHOLE buffer: over the size cap or older
1549
+ * than a day. These flush what would otherwise dangle; they never assert a
1550
+ * topic boundary (quiet-time: a time signal is only ever a fallback).
1551
+ *
1552
+ * Returns `{ entries, trigger }`; `trigger: null` means nothing has left yet.
1553
+ */
1554
+ export function planSegment(entries, env = {}) {
1555
+ const {
1556
+ currentSessionId,
1557
+ markerAt = null,
1558
+ archived = false,
1559
+ now = Date.now(),
1560
+ maxTurns = SEGMENT_MAX_TURNS,
1561
+ maxChars = SEGMENT_MAX_CHARS,
1562
+ maxAgeMs = SEGMENT_MAX_AGE_MS,
1563
+ } = env;
1564
+ if (!Array.isArray(entries) || entries.length === 0) return { entries: [], trigger: null };
1565
+ // Archiving is the user declaring the session finished: everything buffered goes.
1566
+ if (archived) return { entries, trigger: 'archived' };
1567
+ const departed = (entry) => {
1568
+ if (currentSessionId && entry.sessionId && entry.sessionId !== currentSessionId) return 'session';
1569
+ if (markerAt !== null && entry.startedAt <= markerAt) return 'compaction';
1570
+ return null;
1571
+ };
1572
+ const start = entries.findIndex((entry) => departed(entry) !== null);
1573
+ if (start >= 0) {
1574
+ let end = start;
1575
+ while (end < entries.length && departed(entries[end]) !== null) end += 1;
1576
+ const run = entries.slice(start, end);
1577
+ return { entries: run, trigger: departed(run[0]) };
1578
+ }
1579
+ const chars = entries.reduce((sum, entry) => sum + (entry.chars ?? 0), 0);
1580
+ if (entries.length >= maxTurns || chars >= maxChars) return { entries, trigger: 'capacity' };
1581
+ const oldest = Math.min(...entries.map((entry) => entry.startedAt ?? now));
1582
+ if (now - oldest > maxAgeMs) return { entries, trigger: 'age' };
1583
+ return { entries: [], trigger: null };
1411
1584
  }
1412
1585
 
1413
1586
  /**
@@ -1421,12 +1594,19 @@ function removeExtractItemsOfThread(threadId, turnIds) {
1421
1594
  export function enqueueExtraction(turnId, turnNo, origin = 'live') {
1422
1595
  const queue = readExtractQueue();
1423
1596
  if (queue.some((item) => item.turnId === turnId)) return false;
1597
+ // A sealed segment is not re-queued (0028 declaration 1): the seal record is
1598
+ // what makes "extracted at most once" checkable instead of merely intended.
1599
+ // Replays are the documented exception and carry origin=backfill.
1600
+ if (origin !== 'backfill' && readSegmentState().sealed.some((seg) => seg.turnIds.includes(turnId))) {
1601
+ log(`extract buffered: turn=${turnNo} already sealed, refused (a replay needs origin=backfill)`);
1602
+ return false;
1603
+ }
1424
1604
  // `origin` separates live turns from a historical backfill. Without it the attribution
1425
1605
  // experiment counts re-extracted old turns as fresh evidence, and a verdict about the
1426
1606
  // new default-continue rule would be read off history that never saw that rule.
1427
1607
  queue.push({ turnId, turnNo, attempts: 0, origin });
1428
1608
  writeExtractQueue(queue);
1429
- log(`extract queued turn=${turnNo} origin=${origin} pending=${queue.length} file=${EXTRACT_QUEUE_FILE}`);
1609
+ log(`extract buffered turn=${turnNo} origin=${origin} pending=${queue.length} file=${EXTRACT_QUEUE_FILE}`);
1430
1610
  return true;
1431
1611
  }
1432
1612
 
@@ -1443,11 +1623,25 @@ function hasSubstance(text) {
1443
1623
  return !/^(好的?|嗯+|哦+|可以|行|ok|okay|收到|明白|继续|好)[。!!.\s]*$/i.test(trimmed);
1444
1624
  }
1445
1625
 
1446
- async function runExtraction(text, turnId, turnNo, origin = 'live', thread = null) {
1626
+ /**
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.
1633
+ */
1634
+ async function runExtraction(text, turnId, turnNo, origin = 'live', unit = null) {
1447
1635
  const config = readConfig();
1448
1636
  if (config.extract === 'off') return 'skipped-off';
1449
- if (typeof text !== 'string' || !hasSubstance(text)) {
1450
- log(`extract skipped: no substance (chars=${typeof text === 'string' ? text.length : -1}) turn=${turnNo}`);
1637
+ // The unit decides, not its last message: a segment whose final turn is "好的"
1638
+ // 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];
1640
+ if (typeof text !== 'string' || !unitTexts.some((entry) => typeof entry === 'string' && hasSubstance(entry))) {
1641
+ log(
1642
+ `extract skipped: no substance (chars=${unitTexts.reduce((n, entry) => n + (typeof entry === 'string' ? entry.length : 0), 0)})` +
1643
+ ` unit=${unitTexts.length} turn=${turnNo}`,
1644
+ );
1451
1645
  return 'skipped-empty';
1452
1646
  }
1453
1647
  const started = Date.now();
@@ -1465,9 +1659,10 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
1465
1659
  const previousThreadId = soul.threadBefore(turnRow.sessionId, turnNo);
1466
1660
  if (previousThreadId) context.previousThreadId = previousThreadId;
1467
1661
  }
1468
- // 0025: the unit is the whole topic when the turn belongs to one. The turns array is
1469
- // numbered the way the prompt numbers them, and each fact may name its own turn.
1470
- const turns = thread?.turns ?? null;
1662
+ // 0028: the unit is the segment a departure flushed. The turns array is
1663
+ // numbered the way the prompt numbers them (1..n inside the unit), and each
1664
+ // fact may name its own turn — turnIds maps that number back to the real turn.
1665
+ const turns = unit?.turns ?? null;
1471
1666
  const turnIds = turns ? Object.fromEntries(turns.map((entry) => [entry.turnNo, entry.id])) : undefined;
1472
1667
  const draft = await soul.extract(text, {
1473
1668
  llm: useLlm ? injectedLlm : undefined,
@@ -1495,12 +1690,14 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
1495
1690
  // Provenance is observable (ADR 0035): the fact count alone cannot tell whether
1496
1691
  // anything came from my side of the exchange.
1497
1692
  const inferred = result.facts.filter((fact) => fact.source === 'inferred').length;
1693
+ // "done", not "ok": the caller decides the queue outcome next (rules:model-failed
1694
+ // is NOT ok), and the old prefix read as success in every log review.
1498
1695
  log(
1499
- `extract ok: by=${draft.by} reason=${draft.reason ?? '-'} entities=${result.entities.length} facts=${result.facts.length}` +
1696
+ `extract done: by=${draft.by} reason=${draft.reason ?? '-'} entities=${result.entities.length} facts=${result.facts.length}` +
1500
1697
  ` gaps=${result.gaps.length} skipped=${result.skipped.length} draftSkipped=${(draft.skipped ?? []).length}` +
1501
1698
  ` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
1502
1699
  ` inferred=${inferred}` +
1503
- ` unit=${turns ? `${turns.length}turns` : '1turn'} threadId=${thread?.id ?? '-'}` +
1700
+ ` unit=${turns ? `${turns.length}turns` : '1turn'}` +
1504
1701
  ` ms=${ms} turn=${turnNo} origin=${origin}` +
1505
1702
  ` thread=${attribution.threadId}${attribution.created ? '(new)' : ''}` +
1506
1703
  ` join=${attribution.joinReason} threadLine=${draft.threadLine ?? 'rules'}` +
@@ -1549,23 +1746,17 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
1549
1746
  }
1550
1747
 
1551
1748
  let draining = false;
1749
+ /**
1750
+ * The buffered size last reported as a wait (0028 declaration 5 observability).
1751
+ * Reset on every flush so the next wait always logs; between flushes it keeps the
1752
+ * trace to one line per buffer change instead of one per step.
1753
+ */
1754
+ let lastSegmentWait = -1;
1552
1755
  /** True while a stage chain is running, so two chains never overlap. */
1553
1756
  let stageChainBusy = false;
1554
1757
  /** Alternates the profile merge with the rest of the chain so it cannot be starved. */
1555
1758
  let stageTick = 0;
1556
1759
 
1557
- /**
1558
- * Which queued turn to extract next.
1559
- *
1560
- * Live turns first, then history: FIFO alone would put 20 backfill items ahead of the
1561
- * conversation happening right now, so a live turn waits dozens of steps to be read.
1562
- */
1563
- export function pickExtractionItem(queue) {
1564
- // Live turns first, then history. FIFO alone would put 20 backfill items ahead of the
1565
- // conversation happening right now, so a live turn waits dozens of steps to be read.
1566
- return queue.find((item) => (item.origin ?? 'live') !== 'backfill') ?? queue[0] ?? null;
1567
- }
1568
-
1569
1760
  /**
1570
1761
  * Should extraction take this step before consolidation?
1571
1762
  *
@@ -1580,18 +1771,21 @@ export function extractRunsFirst(extractQueue, consolidationPending) {
1580
1771
  }
1581
1772
 
1582
1773
  /**
1583
- * Drain ONE queued turn per step.
1774
+ * Flush ONE segment per step — the whole unit, and only at a departure (0028).
1584
1775
  *
1585
- * The host model is only callable inside the current step: of three serial
1586
- * calls in one drain, the middle one succeeded and the other two failed with the
1587
- * stream gone. One item per step keeps every attempt inside that window; the
1588
- * queue is a backlog, not a batch.
1776
+ * The host model is only callable inside the current step, so at most one model
1777
+ * call happens here. What makes it happen is not "the queue is non-empty" but the
1778
+ * departure checklist (planSegment): compaction marker, session switch/archive,
1779
+ * capacity, age. With none of them the queue only waits — in-session extraction
1780
+ * is 0 by construction, because those turns are still in the host's context and
1781
+ * extracting them again is the duplicate work 0028 exists to stop.
1589
1782
  *
1590
- * A model failure keeps the item, because the rules pass is only a floor: if it
1591
- * counted as done, that turn would never get a model extraction. Three failures
1592
- * drop it with the reason.
1783
+ * A model failure keeps the WHOLE segment: one call covered it, so its items are
1784
+ * bumped together and share one attempt count. Three failures seal and drop the
1785
+ * segment with its size (rule 15) — sealed, so a later backfill cannot re-queue
1786
+ * what already failed three times.
1593
1787
  */
1594
- export async function drainExtractionQueue() {
1788
+ export async function drainExtractionQueue(options = {}) {
1595
1789
  if (draining) return false;
1596
1790
  const config = readConfig();
1597
1791
  if (config.extract === 'off') return false;
@@ -1599,63 +1793,110 @@ export async function drainExtractionQueue() {
1599
1793
  if (queue.length === 0) return false;
1600
1794
  draining = true;
1601
1795
  try {
1602
- const item = pickExtractionItem(queue);
1603
- let text = '';
1604
- let thread = null;
1796
+ // Rows are the source of truth (rule 16): which session a turn belongs to and
1797
+ // when it happened come from `turns`, not from a copy in the queue file. A
1798
+ // failed read keeps the queue — a store error is not an empty result (rule 11).
1799
+ let entries;
1605
1800
  try {
1606
1801
  const soul = ensureSoul();
1607
- const turn = soul.turn(item.turnId);
1608
- text = turn?.userText ?? '';
1609
- // A turn is a trigger, the topic is the unit (0025). When the turn is already in a
1610
- // thread, extract that thread's turns instead of this message alone.
1611
- const threadId = soul.turnThreadId(item.turnId);
1612
- if (threadId) {
1613
- const turns = soul.threadTurns(threadId);
1614
- if (turns.length > 0) {
1615
- thread = { id: threadId, turns };
1616
- text = turns[turns.length - 1].userText;
1802
+ entries = [];
1803
+ for (const item of queue) {
1804
+ const row = soul.turn(item.turnId);
1805
+ if (!row) {
1806
+ log(`extract queue: turn ${item.turnId} missing, dropped`);
1807
+ removeExtractItem(item.turnId);
1808
+ continue;
1617
1809
  }
1810
+ entries.push({
1811
+ item,
1812
+ row,
1813
+ sessionId: row.sessionId,
1814
+ startedAt: row.at,
1815
+ chars: row.userText.length + row.assistantText.length,
1816
+ });
1618
1817
  }
1619
- } catch {
1620
- text = '';
1818
+ } catch (error) {
1819
+ log(`segment resolve failed (queue kept): ${error instanceof Error ? error.message : String(error)}`);
1820
+ return false;
1621
1821
  }
1622
- if (!text) {
1623
- log(`extract queue: turn ${item.turnId} missing, dropped`);
1624
- removeExtractItem(item.turnId);
1625
- return;
1822
+ if (entries.length === 0) return false;
1823
+ const state = readSegmentState();
1824
+ const plan = planSegment(entries, {
1825
+ currentSessionId: options.sessionId,
1826
+ markerAt: state.marker?.at ?? null,
1827
+ archived: Boolean(options.sessionId && (options.archivedSessions ?? []).includes(options.sessionId)),
1828
+ now: Date.now(),
1829
+ });
1830
+ if (!plan.trigger) {
1831
+ // A marker whose covered turns are all gone can never fire again (time only
1832
+ // moves forward); leaving it would look like a pending departure forever.
1833
+ if (state.marker) {
1834
+ writeSegmentState({ ...state, marker: null });
1835
+ log(`segment marker: nothing left to flush (at=${state.marker.at}), cleared`);
1836
+ }
1837
+ // One wait line per buffer change, not per step: the queue's own backlog log
1838
+ // covers writes, this one makes declaration 5 (no departure = no extraction)
1839
+ // observable in production.
1840
+ if (entries.length !== lastSegmentWait) {
1841
+ lastSegmentWait = entries.length;
1842
+ trace({ event: 'segment-wait', buffered: entries.length, session: options.sessionId ?? null });
1843
+ }
1844
+ return false;
1626
1845
  }
1627
-
1628
- const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live', thread);
1846
+ const taken = plan.entries;
1847
+ const head = taken[0];
1848
+ const last = taken[taken.length - 1];
1849
+ const chars = taken.reduce((sum, entry) => sum + entry.chars, 0);
1850
+ // Prompt numbering is 1..n inside the unit; turnIds maps it back to the real
1851
+ // turn, so a fact's 轮次 lands on source_turn_id.
1852
+ const turns = taken.map((entry, index) => ({
1853
+ id: entry.item.turnId,
1854
+ turnNo: index + 1,
1855
+ userText: entry.row.userText,
1856
+ assistantText: entry.row.assistantText,
1857
+ startedAt: entry.row.at,
1858
+ }));
1859
+ const origin = taken.every((entry) => (entry.item.origin ?? 'live') === 'backfill') ? 'backfill' : 'live';
1860
+ log(
1861
+ `segment flush: trigger=${plan.trigger} turns=${turns.length} chars=${chars}` +
1862
+ ` head=${head.item.turnNo} origin=${origin}`,
1863
+ );
1864
+ const outcome = await runExtraction(last.row.userText, head.item.turnId, head.item.turnNo, origin, { turns });
1629
1865
  const ok =
1630
1866
  outcome === 'ok' || outcome === 'skipped-off' || outcome === 'skipped-empty' || outcome === 'rules-mode';
1631
1867
  if (ok) {
1632
- if (thread) {
1633
- removeExtractItemsOfThread(thread.id, thread.turns.map((entry) => entry.id));
1634
- // Promotion is decided after the facts exist; a whole thread can hold several
1635
- // corrected turns, so every turn is offered.
1636
- for (const entry of thread.turns) feedClassifyFromTurn(entry.id);
1637
- } else {
1638
- removeExtractItem(item.turnId);
1639
- // Promotion is decided after the facts exist: a corrected turn's lessons enter
1640
- // the classification queue here, which is the only live producer it has.
1641
- feedClassifyFromTurn(item.turnId);
1642
- }
1643
- trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome });
1868
+ // Promotion is decided after the facts exist: a corrected turn's lessons enter
1869
+ // the classification queue here, which is the only live producer it has.
1870
+ for (const { item } of taken) feedClassifyFromTurn(item.turnId);
1871
+ removeExtractItems(taken.map((entry) => entry.item.turnId));
1872
+ sealSegment({
1873
+ trigger: plan.trigger,
1874
+ turnIds: taken.map((entry) => entry.item.turnId),
1875
+ turns: taken.length,
1876
+ chars,
1877
+ outcome,
1878
+ session: head.sessionId,
1879
+ });
1880
+ lastSegmentWait = -1;
1881
+ trace({ event: 'extract-queue', drained: taken.length, remaining: readExtractQueue().length, outcome, trigger: plan.trigger });
1644
1882
  return true;
1645
1883
  }
1646
1884
 
1647
1885
  // A rules result caused by a broken model call is not a completed extraction.
1648
- const attempts = (item.attempts ?? 0) + 1;
1886
+ const attempts = (head.item.attempts ?? 0) + 1;
1887
+ const turnIds = taken.map((entry) => entry.item.turnId);
1649
1888
  if (attempts >= EXTRACT_MAX_ATTEMPTS) {
1650
- log(`extract queue: giving up on turn=${item.turnNo} after ${attempts}: ${outcome}`);
1651
- removeExtractItem(item.turnId);
1889
+ log(`extract queue: giving up on segment trigger=${plan.trigger} turns=${taken.length} after ${attempts}: ${outcome}`);
1890
+ removeExtractItems(turnIds);
1891
+ sealSegment({ trigger: plan.trigger, turnIds, turns: taken.length, chars, outcome: `given-up:${outcome}`, session: head.sessionId });
1892
+ trace({ event: 'extract-queue', drained: taken.length, remaining: readExtractQueue().length, outcome, dropped: true, trigger: plan.trigger });
1652
1893
  return true;
1653
1894
  }
1654
- log(`extract queue: retry turn=${item.turnNo} attempt=${attempts} (${outcome})`);
1655
1895
  // Bump in place, computed from a fresh read: an item enqueued during the model
1656
1896
  // call must not be overwritten by a stale queue snapshot.
1657
- bumpExtractAttempt(item.turnId);
1658
- trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome, retry: true });
1897
+ for (const { item } of taken) bumpExtractAttempt(item.turnId);
1898
+ log(`extract queue: retry segment trigger=${plan.trigger} attempt=${attempts} turns=${taken.length} (${outcome})`);
1899
+ trace({ event: 'extract-queue', drained: taken.length, remaining: readExtractQueue().length, outcome, retry: true, trigger: plan.trigger });
1659
1900
  return true;
1660
1901
  } finally {
1661
1902
  draining = false;
@@ -2734,6 +2975,25 @@ export function apply(ctx) {
2734
2975
  return;
2735
2976
  }
2736
2977
 
2978
+ // 0028 决定 2: compaction is the host saying "this left the window". A marker
2979
+ // only — events never call the model (it is callable inside a step), and the
2980
+ // next drain flushes what the marker covers. An errored compaction changed
2981
+ // nothing, so it must leave no marker: a false departure would write durable
2982
+ // memory for content that is still in the context.
2983
+ if (type === 'compaction/end') {
2984
+ if (event.data?.error) {
2985
+ log(`segment marker: compaction failed (${event.data.error}), window unchanged`);
2986
+ } else {
2987
+ setSegmentMarker({
2988
+ trigger: 'compaction',
2989
+ session: sessionId,
2990
+ at: Date.now(),
2991
+ compactionId: event.data?.compactionId ?? null,
2992
+ });
2993
+ }
2994
+ return;
2995
+ }
2996
+
2737
2997
  if (type !== 'user/message') return;
2738
2998
 
2739
2999
  const data = event.data ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-soulbridge",
3
- "version": "0.0.118",
3
+ "version": "0.0.120",
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",