dsh-plugin-soulbridge 0.0.119 → 0.0.121
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 +356 -88
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -1116,6 +1116,12 @@ export function createPreStepHandler(deps) {
|
|
|
1116
1116
|
const decision = await next();
|
|
1117
1117
|
try {
|
|
1118
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?.() ?? [];
|
|
1119
1125
|
// We are inside a live turn now, which is the only place the host model is
|
|
1120
1126
|
// callable. Stages run in priority order and a stage that skipped its work
|
|
1121
1127
|
// must not claim the call — that is how the classifier starved for an hour.
|
|
@@ -1141,9 +1147,12 @@ export function createPreStepHandler(deps) {
|
|
|
1141
1147
|
// Live conversation before everything; understanding before the backfill.
|
|
1142
1148
|
// Fixed priority (extract always first) plus a long backfill is a starvation
|
|
1143
1149
|
// bug, not a preference: 29 closed threads waited while the backfill drained.
|
|
1144
|
-
|
|
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);
|
|
1145
1154
|
stages.push(runConsolidationStep);
|
|
1146
|
-
stages.push(
|
|
1155
|
+
stages.push(drainSegment);
|
|
1147
1156
|
if (profilePending && stageTick % 2 === 0) stages.push(runProfileStep);
|
|
1148
1157
|
stages.push(runClassifyStep);
|
|
1149
1158
|
if (profilePending && stageTick % 2 === 1) stages.push(runProfileStep);
|
|
@@ -1175,7 +1184,6 @@ export function createPreStepHandler(deps) {
|
|
|
1175
1184
|
// Topics the conversation has moved past, closed once per turn. Counting
|
|
1176
1185
|
// actual turns — the host's turn numbers jump by 10–30 per exchange, so a
|
|
1177
1186
|
// numeric difference closed a topic one message after it opened.
|
|
1178
|
-
const sessionId = payload?.agent?.id ?? payload?.agent?.session?.id ?? 'default';
|
|
1179
1187
|
try {
|
|
1180
1188
|
const closeConfig = readConfig();
|
|
1181
1189
|
const closed = ensureSoul().closeIdleThreads(sessionId, {
|
|
@@ -1183,7 +1191,7 @@ export function createPreStepHandler(deps) {
|
|
|
1183
1191
|
idleMs: (closeConfig.threadIdleMinutes ?? 30) * 60_000,
|
|
1184
1192
|
// The host's own archive list: a session the user put away is finished
|
|
1185
1193
|
// by declaration, so its topics close immediately.
|
|
1186
|
-
archivedSessionIds:
|
|
1194
|
+
archivedSessionIds: archivedSessions,
|
|
1187
1195
|
at: Date.now(),
|
|
1188
1196
|
});
|
|
1189
1197
|
if (closed.length > 0) {
|
|
@@ -1352,6 +1360,17 @@ function flushTurnPair(sessionId) {
|
|
|
1352
1360
|
const EXTRACT_QUEUE_FILE = join(HOME, 'pending-extract.json');
|
|
1353
1361
|
const EXTRACT_MAX_ATTEMPTS = 3;
|
|
1354
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
|
+
|
|
1355
1374
|
/**
|
|
1356
1375
|
* Read a queue file.
|
|
1357
1376
|
*
|
|
@@ -1422,14 +1441,146 @@ function bumpExtractAttempt(turnId) {
|
|
|
1422
1441
|
}
|
|
1423
1442
|
|
|
1424
1443
|
/**
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
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.
|
|
1427
1449
|
*/
|
|
1428
|
-
function
|
|
1429
|
-
|
|
1430
|
-
writeExtractQueue(
|
|
1431
|
-
|
|
1432
|
-
|
|
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.
|
|
1465
|
+
*/
|
|
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 };
|
|
1433
1584
|
}
|
|
1434
1585
|
|
|
1435
1586
|
/**
|
|
@@ -1443,12 +1594,19 @@ function removeExtractItemsOfThread(threadId, turnIds) {
|
|
|
1443
1594
|
export function enqueueExtraction(turnId, turnNo, origin = 'live') {
|
|
1444
1595
|
const queue = readExtractQueue();
|
|
1445
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
|
+
}
|
|
1446
1604
|
// `origin` separates live turns from a historical backfill. Without it the attribution
|
|
1447
1605
|
// experiment counts re-extracted old turns as fresh evidence, and a verdict about the
|
|
1448
1606
|
// new default-continue rule would be read off history that never saw that rule.
|
|
1449
1607
|
queue.push({ turnId, turnNo, attempts: 0, origin });
|
|
1450
1608
|
writeExtractQueue(queue);
|
|
1451
|
-
log(`extract
|
|
1609
|
+
log(`extract buffered turn=${turnNo} origin=${origin} pending=${queue.length} file=${EXTRACT_QUEUE_FILE}`);
|
|
1452
1610
|
return true;
|
|
1453
1611
|
}
|
|
1454
1612
|
|
|
@@ -1465,11 +1623,25 @@ function hasSubstance(text) {
|
|
|
1465
1623
|
return !/^(好的?|嗯+|哦+|可以|行|ok|okay|收到|明白|继续|好)[。!!.\s]*$/i.test(trimmed);
|
|
1466
1624
|
}
|
|
1467
1625
|
|
|
1468
|
-
|
|
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) {
|
|
1469
1635
|
const config = readConfig();
|
|
1470
1636
|
if (config.extract === 'off') return 'skipped-off';
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
+
);
|
|
1473
1645
|
return 'skipped-empty';
|
|
1474
1646
|
}
|
|
1475
1647
|
const started = Date.now();
|
|
@@ -1487,9 +1659,10 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
|
|
|
1487
1659
|
const previousThreadId = soul.threadBefore(turnRow.sessionId, turnNo);
|
|
1488
1660
|
if (previousThreadId) context.previousThreadId = previousThreadId;
|
|
1489
1661
|
}
|
|
1490
|
-
//
|
|
1491
|
-
// numbered the way the prompt numbers them
|
|
1492
|
-
|
|
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;
|
|
1493
1666
|
const turnIds = turns ? Object.fromEntries(turns.map((entry) => [entry.turnNo, entry.id])) : undefined;
|
|
1494
1667
|
const draft = await soul.extract(text, {
|
|
1495
1668
|
llm: useLlm ? injectedLlm : undefined,
|
|
@@ -1504,15 +1677,30 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
|
|
|
1504
1677
|
});
|
|
1505
1678
|
const result = soul.commitDrafts(draft, { turnId, turnIds });
|
|
1506
1679
|
// Attribution is written even when nothing was extracted: a turn that says
|
|
1507
|
-
// "无" (nothing worth remembering) can still be part of a thread.
|
|
1508
|
-
|
|
1680
|
+
// "无" (nothing worth remembering) can still be part of a thread. The unit is
|
|
1681
|
+
// fanned over per turn (0028 ③): every turn of the segment gets attached, a
|
|
1682
|
+
// turn no span covers chains to the previous one, and the continue/new/
|
|
1683
|
+
// fallback chain inside applyThread is untouched (ADR 0034).
|
|
1684
|
+
const spanTurns = (turns ?? [{ turnNo: 1, id: turnId, userText: text }]).map((entry) => ({
|
|
1685
|
+
promptNo: entry.turnNo,
|
|
1686
|
+
turnNo: entry.hostTurnNo ?? turnNo,
|
|
1687
|
+
turnId: entry.id,
|
|
1688
|
+
at: entry.startedAt ?? Date.now(),
|
|
1689
|
+
fallbackTopic: firstSentence(entry.userText ?? text),
|
|
1690
|
+
}));
|
|
1691
|
+
const applied = soul.applyThreadSpans(draft.spans, {
|
|
1509
1692
|
sessionId: turnRow?.sessionId ?? 'unknown',
|
|
1510
|
-
|
|
1511
|
-
turnId,
|
|
1512
|
-
at: Date.now(),
|
|
1513
|
-
fallbackTopic: firstSentence(text),
|
|
1693
|
+
turns: spanTurns,
|
|
1514
1694
|
previousThreadId: context.previousThreadId,
|
|
1515
1695
|
});
|
|
1696
|
+
const attribution = applied[0];
|
|
1697
|
+
// One thread keeps the old line's thread part byte for byte (`thread=th_xxx(new)`);
|
|
1698
|
+
// a split segment lists every thread it produced, and `join=` is the unit's
|
|
1699
|
+
// per-turn reasons joined with `+` (one value only when they agree) — the head
|
|
1700
|
+
// alone no longer describes what the rest of the segment did.
|
|
1701
|
+
const threadIds = [...new Set(applied.map((entry) => entry.threadId))];
|
|
1702
|
+
const createdNew = applied.filter((entry) => entry.created).length;
|
|
1703
|
+
const joins = [...new Set(applied.map((entry) => entry.joinReason))].join('+');
|
|
1516
1704
|
const ms = Date.now() - started;
|
|
1517
1705
|
// Provenance is observable (ADR 0035): the fact count alone cannot tell whether
|
|
1518
1706
|
// anything came from my side of the exchange.
|
|
@@ -1524,11 +1712,12 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
|
|
|
1524
1712
|
` gaps=${result.gaps.length} skipped=${result.skipped.length} draftSkipped=${(draft.skipped ?? []).length}` +
|
|
1525
1713
|
` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
|
|
1526
1714
|
` inferred=${inferred}` +
|
|
1527
|
-
` unit=${turns ? `${turns.length}turns` : '1turn'}
|
|
1715
|
+
` unit=${turns ? `${turns.length}turns` : '1turn'}` +
|
|
1528
1716
|
` ms=${ms} turn=${turnNo} origin=${origin}` +
|
|
1529
|
-
` thread=${
|
|
1530
|
-
|
|
1531
|
-
|
|
1717
|
+
` thread=${threadIds.join(',')}` +
|
|
1718
|
+
`${createdNew > 0 ? (threadIds.length === 1 ? '(new)' : `(${createdNew} new)`) : ''}` +
|
|
1719
|
+
` join=${joins} threadLine=${draft.threadLine ?? 'rules'}` +
|
|
1720
|
+
`${applied.some((entry) => entry.topicUpdated) ? ' topic=updated' : ''}`,
|
|
1532
1721
|
);
|
|
1533
1722
|
for (const skipped of result.skipped.slice(0, 3)) {
|
|
1534
1723
|
log(` extract skipped: ${skipped.what} (${skipped.reason})`);
|
|
@@ -1559,6 +1748,18 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
|
|
|
1559
1748
|
threadTopic: attribution.topic,
|
|
1560
1749
|
threadLine: draft.threadLine ?? null,
|
|
1561
1750
|
topicUpdated: attribution.topicUpdated,
|
|
1751
|
+
// Only when the segment actually split: one thread is the old shape, and the
|
|
1752
|
+
// per-turn runs are what makes a wrong split diagnosable later (0028 ③).
|
|
1753
|
+
...(applied.length > 1
|
|
1754
|
+
? {
|
|
1755
|
+
threadSpans: applied.map((entry, index) => ({
|
|
1756
|
+
span: spanTurns[index].promptNo,
|
|
1757
|
+
thread: entry.threadId,
|
|
1758
|
+
join: entry.joinReason,
|
|
1759
|
+
created: entry.created,
|
|
1760
|
+
})),
|
|
1761
|
+
}
|
|
1762
|
+
: {}),
|
|
1562
1763
|
origin,
|
|
1563
1764
|
joinReason: attribution.joinReason,
|
|
1564
1765
|
ms,
|
|
@@ -1573,23 +1774,17 @@ async function runExtraction(text, turnId, turnNo, origin = 'live', thread = nul
|
|
|
1573
1774
|
}
|
|
1574
1775
|
|
|
1575
1776
|
let draining = false;
|
|
1777
|
+
/**
|
|
1778
|
+
* The buffered size last reported as a wait (0028 declaration 5 observability).
|
|
1779
|
+
* Reset on every flush so the next wait always logs; between flushes it keeps the
|
|
1780
|
+
* trace to one line per buffer change instead of one per step.
|
|
1781
|
+
*/
|
|
1782
|
+
let lastSegmentWait = -1;
|
|
1576
1783
|
/** True while a stage chain is running, so two chains never overlap. */
|
|
1577
1784
|
let stageChainBusy = false;
|
|
1578
1785
|
/** Alternates the profile merge with the rest of the chain so it cannot be starved. */
|
|
1579
1786
|
let stageTick = 0;
|
|
1580
1787
|
|
|
1581
|
-
/**
|
|
1582
|
-
* Which queued turn to extract next.
|
|
1583
|
-
*
|
|
1584
|
-
* Live turns first, then history: FIFO alone would put 20 backfill items ahead of the
|
|
1585
|
-
* conversation happening right now, so a live turn waits dozens of steps to be read.
|
|
1586
|
-
*/
|
|
1587
|
-
export function pickExtractionItem(queue) {
|
|
1588
|
-
// Live turns first, then history. FIFO alone would put 20 backfill items ahead of the
|
|
1589
|
-
// conversation happening right now, so a live turn waits dozens of steps to be read.
|
|
1590
|
-
return queue.find((item) => (item.origin ?? 'live') !== 'backfill') ?? queue[0] ?? null;
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
1788
|
/**
|
|
1594
1789
|
* Should extraction take this step before consolidation?
|
|
1595
1790
|
*
|
|
@@ -1604,18 +1799,21 @@ export function extractRunsFirst(extractQueue, consolidationPending) {
|
|
|
1604
1799
|
}
|
|
1605
1800
|
|
|
1606
1801
|
/**
|
|
1607
|
-
*
|
|
1802
|
+
* Flush ONE segment per step — the whole unit, and only at a departure (0028).
|
|
1608
1803
|
*
|
|
1609
|
-
* The host model is only callable inside the current step
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
* queue
|
|
1804
|
+
* The host model is only callable inside the current step, so at most one model
|
|
1805
|
+
* call happens here. What makes it happen is not "the queue is non-empty" but the
|
|
1806
|
+
* departure checklist (planSegment): compaction marker, session switch/archive,
|
|
1807
|
+
* capacity, age. With none of them the queue only waits — in-session extraction
|
|
1808
|
+
* is 0 by construction, because those turns are still in the host's context and
|
|
1809
|
+
* extracting them again is the duplicate work 0028 exists to stop.
|
|
1613
1810
|
*
|
|
1614
|
-
* A model failure keeps the
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1811
|
+
* A model failure keeps the WHOLE segment: one call covered it, so its items are
|
|
1812
|
+
* bumped together and share one attempt count. Three failures seal and drop the
|
|
1813
|
+
* segment with its size (rule 15) — sealed, so a later backfill cannot re-queue
|
|
1814
|
+
* what already failed three times.
|
|
1617
1815
|
*/
|
|
1618
|
-
export async function drainExtractionQueue() {
|
|
1816
|
+
export async function drainExtractionQueue(options = {}) {
|
|
1619
1817
|
if (draining) return false;
|
|
1620
1818
|
const config = readConfig();
|
|
1621
1819
|
if (config.extract === 'off') return false;
|
|
@@ -1623,63 +1821,114 @@ export async function drainExtractionQueue() {
|
|
|
1623
1821
|
if (queue.length === 0) return false;
|
|
1624
1822
|
draining = true;
|
|
1625
1823
|
try {
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1824
|
+
// Rows are the source of truth (rule 16): which session a turn belongs to and
|
|
1825
|
+
// when it happened come from `turns`, not from a copy in the queue file. A
|
|
1826
|
+
// failed read keeps the queue — a store error is not an empty result (rule 11).
|
|
1827
|
+
let entries;
|
|
1629
1828
|
try {
|
|
1630
1829
|
const soul = ensureSoul();
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
if (turns.length > 0) {
|
|
1639
|
-
thread = { id: threadId, turns };
|
|
1640
|
-
text = turns[turns.length - 1].userText;
|
|
1830
|
+
entries = [];
|
|
1831
|
+
for (const item of queue) {
|
|
1832
|
+
const row = soul.turn(item.turnId);
|
|
1833
|
+
if (!row) {
|
|
1834
|
+
log(`extract queue: turn ${item.turnId} missing, dropped`);
|
|
1835
|
+
removeExtractItem(item.turnId);
|
|
1836
|
+
continue;
|
|
1641
1837
|
}
|
|
1838
|
+
entries.push({
|
|
1839
|
+
item,
|
|
1840
|
+
row,
|
|
1841
|
+
sessionId: row.sessionId,
|
|
1842
|
+
startedAt: row.at,
|
|
1843
|
+
chars: row.userText.length + row.assistantText.length,
|
|
1844
|
+
});
|
|
1642
1845
|
}
|
|
1643
|
-
} catch {
|
|
1644
|
-
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
log(`segment resolve failed (queue kept): ${error instanceof Error ? error.message : String(error)}`);
|
|
1848
|
+
return false;
|
|
1645
1849
|
}
|
|
1646
|
-
if (
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1850
|
+
if (entries.length === 0) return false;
|
|
1851
|
+
const state = readSegmentState();
|
|
1852
|
+
const plan = planSegment(entries, {
|
|
1853
|
+
currentSessionId: options.sessionId,
|
|
1854
|
+
markerAt: state.marker?.at ?? null,
|
|
1855
|
+
archived: Boolean(options.sessionId && (options.archivedSessions ?? []).includes(options.sessionId)),
|
|
1856
|
+
now: Date.now(),
|
|
1857
|
+
});
|
|
1858
|
+
if (!plan.trigger) {
|
|
1859
|
+
// A marker whose covered turns are all gone can never fire again (time only
|
|
1860
|
+
// moves forward); leaving it would look like a pending departure forever.
|
|
1861
|
+
if (state.marker) {
|
|
1862
|
+
writeSegmentState({ ...state, marker: null });
|
|
1863
|
+
log(`segment marker: nothing left to flush (at=${state.marker.at}), cleared`);
|
|
1864
|
+
}
|
|
1865
|
+
// One wait line per buffer change, not per step: the queue's own backlog log
|
|
1866
|
+
// covers writes, this one makes declaration 5 (no departure = no extraction)
|
|
1867
|
+
// observable in production.
|
|
1868
|
+
if (entries.length !== lastSegmentWait) {
|
|
1869
|
+
lastSegmentWait = entries.length;
|
|
1870
|
+
trace({ event: 'segment-wait', buffered: entries.length, session: options.sessionId ?? null });
|
|
1871
|
+
}
|
|
1872
|
+
return false;
|
|
1650
1873
|
}
|
|
1651
|
-
|
|
1652
|
-
const
|
|
1874
|
+
const taken = plan.entries;
|
|
1875
|
+
const head = taken[0];
|
|
1876
|
+
const last = taken[taken.length - 1];
|
|
1877
|
+
const chars = taken.reduce((sum, entry) => sum + entry.chars, 0);
|
|
1878
|
+
// Prompt numbering is 1..n inside the unit; turnIds maps it back to the real
|
|
1879
|
+
// turn, so a fact's 轮次 lands on source_turn_id.
|
|
1880
|
+
const turns = taken.map((entry, index) => ({
|
|
1881
|
+
id: entry.item.turnId,
|
|
1882
|
+
// Prompt numbering (1..n, what the model's spans match) and the host's real
|
|
1883
|
+
// turn number (threads count turns that jump by 10–30) are different numbers;
|
|
1884
|
+
// both ride along so attribution never has to guess which is which.
|
|
1885
|
+
turnNo: index + 1,
|
|
1886
|
+
hostTurnNo: entry.row.turn,
|
|
1887
|
+
userText: entry.row.userText,
|
|
1888
|
+
assistantText: entry.row.assistantText,
|
|
1889
|
+
startedAt: entry.row.at,
|
|
1890
|
+
}));
|
|
1891
|
+
const origin = taken.every((entry) => (entry.item.origin ?? 'live') === 'backfill') ? 'backfill' : 'live';
|
|
1892
|
+
log(
|
|
1893
|
+
`segment flush: trigger=${plan.trigger} turns=${turns.length} chars=${chars}` +
|
|
1894
|
+
` head=${head.item.turnNo} origin=${origin}`,
|
|
1895
|
+
);
|
|
1896
|
+
const outcome = await runExtraction(last.row.userText, head.item.turnId, head.item.turnNo, origin, { turns });
|
|
1653
1897
|
const ok =
|
|
1654
1898
|
outcome === 'ok' || outcome === 'skipped-off' || outcome === 'skipped-empty' || outcome === 'rules-mode';
|
|
1655
1899
|
if (ok) {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1900
|
+
// Promotion is decided after the facts exist: a corrected turn's lessons enter
|
|
1901
|
+
// the classification queue here, which is the only live producer it has.
|
|
1902
|
+
for (const { item } of taken) feedClassifyFromTurn(item.turnId);
|
|
1903
|
+
removeExtractItems(taken.map((entry) => entry.item.turnId));
|
|
1904
|
+
sealSegment({
|
|
1905
|
+
trigger: plan.trigger,
|
|
1906
|
+
turnIds: taken.map((entry) => entry.item.turnId),
|
|
1907
|
+
turns: taken.length,
|
|
1908
|
+
chars,
|
|
1909
|
+
outcome,
|
|
1910
|
+
session: head.sessionId,
|
|
1911
|
+
});
|
|
1912
|
+
lastSegmentWait = -1;
|
|
1913
|
+
trace({ event: 'extract-queue', drained: taken.length, remaining: readExtractQueue().length, outcome, trigger: plan.trigger });
|
|
1668
1914
|
return true;
|
|
1669
1915
|
}
|
|
1670
1916
|
|
|
1671
1917
|
// A rules result caused by a broken model call is not a completed extraction.
|
|
1672
|
-
const attempts = (item.attempts ?? 0) + 1;
|
|
1918
|
+
const attempts = (head.item.attempts ?? 0) + 1;
|
|
1919
|
+
const turnIds = taken.map((entry) => entry.item.turnId);
|
|
1673
1920
|
if (attempts >= EXTRACT_MAX_ATTEMPTS) {
|
|
1674
|
-
log(`extract queue: giving up on
|
|
1675
|
-
|
|
1921
|
+
log(`extract queue: giving up on segment trigger=${plan.trigger} turns=${taken.length} after ${attempts}: ${outcome}`);
|
|
1922
|
+
removeExtractItems(turnIds);
|
|
1923
|
+
sealSegment({ trigger: plan.trigger, turnIds, turns: taken.length, chars, outcome: `given-up:${outcome}`, session: head.sessionId });
|
|
1924
|
+
trace({ event: 'extract-queue', drained: taken.length, remaining: readExtractQueue().length, outcome, dropped: true, trigger: plan.trigger });
|
|
1676
1925
|
return true;
|
|
1677
1926
|
}
|
|
1678
|
-
log(`extract queue: retry turn=${item.turnNo} attempt=${attempts} (${outcome})`);
|
|
1679
1927
|
// Bump in place, computed from a fresh read: an item enqueued during the model
|
|
1680
1928
|
// call must not be overwritten by a stale queue snapshot.
|
|
1681
|
-
bumpExtractAttempt(item.turnId);
|
|
1682
|
-
|
|
1929
|
+
for (const { item } of taken) bumpExtractAttempt(item.turnId);
|
|
1930
|
+
log(`extract queue: retry segment trigger=${plan.trigger} attempt=${attempts} turns=${taken.length} (${outcome})`);
|
|
1931
|
+
trace({ event: 'extract-queue', drained: taken.length, remaining: readExtractQueue().length, outcome, retry: true, trigger: plan.trigger });
|
|
1683
1932
|
return true;
|
|
1684
1933
|
} finally {
|
|
1685
1934
|
draining = false;
|
|
@@ -2758,6 +3007,25 @@ export function apply(ctx) {
|
|
|
2758
3007
|
return;
|
|
2759
3008
|
}
|
|
2760
3009
|
|
|
3010
|
+
// 0028 决定 2: compaction is the host saying "this left the window". A marker
|
|
3011
|
+
// only — events never call the model (it is callable inside a step), and the
|
|
3012
|
+
// next drain flushes what the marker covers. An errored compaction changed
|
|
3013
|
+
// nothing, so it must leave no marker: a false departure would write durable
|
|
3014
|
+
// memory for content that is still in the context.
|
|
3015
|
+
if (type === 'compaction/end') {
|
|
3016
|
+
if (event.data?.error) {
|
|
3017
|
+
log(`segment marker: compaction failed (${event.data.error}), window unchanged`);
|
|
3018
|
+
} else {
|
|
3019
|
+
setSegmentMarker({
|
|
3020
|
+
trigger: 'compaction',
|
|
3021
|
+
session: sessionId,
|
|
3022
|
+
at: Date.now(),
|
|
3023
|
+
compactionId: event.data?.compactionId ?? null,
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
2761
3029
|
if (type !== 'user/message') return;
|
|
2762
3030
|
|
|
2763
3031
|
const data = event.data ?? {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-soulbridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
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
|
}
|