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.
- package/index.js +367 -107
- 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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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:
|
|
1082
|
-
theme:
|
|
1083
|
-
hasSummary:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
*
|
|
1404
|
-
*
|
|
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
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
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
|
-
//
|
|
1469
|
-
// numbered the way the prompt numbers them
|
|
1470
|
-
|
|
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
|
|
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'}
|
|
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
|
-
*
|
|
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
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* queue
|
|
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
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
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
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
-
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
log(`segment resolve failed (queue kept): ${error instanceof Error ? error.message : String(error)}`);
|
|
1820
|
+
return false;
|
|
1621
1821
|
}
|
|
1622
|
-
if (
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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
|
|
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
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
|
1651
|
-
|
|
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
|
-
|
|
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 ?? {};
|