dsh-switchman 1.11.5 → 1.11.7
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/README.de.md +36 -29
- package/README.es.md +36 -29
- package/README.fr.md +36 -29
- package/README.it.md +36 -29
- package/README.ja.md +36 -29
- package/README.ko.md +36 -29
- package/README.md +38 -31
- package/README.pt.md +36 -29
- package/README.ru.md +36 -29
- package/README.zh-TW.md +36 -29
- package/README.zh.md +36 -29
- package/client.js +445 -35
- package/cordis.patch.yml +25 -9
- package/host/config.js +75 -30
- package/host/context-trim/index.js +806 -51
- package/host/context-trim/state.js +3 -2
- package/host/context-trim/surface.js +218 -7
- package/host/context-trim/trigger.js +29 -12
- package/host/context-trim/view.js +17 -2
- package/host/context-watch.js +17 -8
- package/host/intent.js +41 -4
- package/host/lang.js +4 -2
- package/host/ptc-bindings.js +510 -0
- package/host/ptc-guard.js +9 -1
- package/host/routes.js +111 -32
- package/host/teams.js +61 -15
- package/host/vision.js +24 -3
- package/index.js +5 -2
- package/package.json +1 -1
|
@@ -523,8 +523,9 @@ export function ledgerFoldedTokens(messages, ledger) {
|
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
/** Ladder census of one ledger: the ACTIVE tier-1 and tier-2 populations
|
|
526
|
-
* (id + summary mass estimate + approxTokens)
|
|
527
|
-
*
|
|
526
|
+
* (id + summary mass estimate + approxTokens). Test/inspection helper —
|
|
527
|
+
* NOT read by the trigger: production uses index.js ladderRunsOf, which
|
|
528
|
+
* also collects tier-3 runs for T3 planning. Pure; never throws. */
|
|
528
529
|
export function ladderCensusOf(ledger, countTokens = null) {
|
|
529
530
|
const estimate = typeof countTokens === "function"
|
|
530
531
|
? countTokens
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
* and every no-op with pending work is logged to decisions.log.
|
|
85
85
|
*/
|
|
86
86
|
|
|
87
|
-
import { refOf, summaryMessageOf } from "./view.js";
|
|
87
|
+
import { refOf, summaryMessageOf, isFoldSummaryMessage, FOLD_SUMMARY_ID_PREFIX } from "./view.js";
|
|
88
88
|
|
|
89
89
|
/** Ladder-block probe (tier ≥ 2): a distillation whose surface material is
|
|
90
90
|
* the REPLACEMENT NODES of its consumed blocks, not a raw ref range. */
|
|
@@ -545,6 +545,147 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
545
545
|
}
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
function nudgeTextOf(message) {
|
|
549
|
+
try {
|
|
550
|
+
const content = message?.content;
|
|
551
|
+
if (typeof content === "string") return content;
|
|
552
|
+
if (!Array.isArray(content)) return "";
|
|
553
|
+
return content.map((part) => typeof part?.text === "string" ? part.text : "").join("");
|
|
554
|
+
} catch {
|
|
555
|
+
return "";
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
let nudgeCounter = 0; // unique suffix per speak: colliding explicit ids would collide in ref resolution
|
|
559
|
+
function nudgeMessage(sessionId, text) {
|
|
560
|
+
const token = String(sessionId ?? "").replace(/[^A-Za-z0-9_-]/gu, "_").slice(0, 128) || "session";
|
|
561
|
+
return {
|
|
562
|
+
id: "trim_nudge_" + token + "_" + (++nudgeCounter),
|
|
563
|
+
role: "user",
|
|
564
|
+
content: [{ type: "text", text: String(text ?? "") }],
|
|
565
|
+
source: { kind: "user" },
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** Surface nudge availability excludes sessions with an open compaction pair. */
|
|
569
|
+
function nudgeSupported(sessionId) {
|
|
570
|
+
try {
|
|
571
|
+
const session = sessionOf(sessionId);
|
|
572
|
+
return surfaceCapable(session) && !lockOpenCached(sessionId, session);
|
|
573
|
+
} catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/** Append one advisory nudge as a suffix-only user message. Returns
|
|
578
|
+
* { seq, error }: seq is null when the host rejected the append, and error
|
|
579
|
+
* carries the rejection message so the skip row can name the real cause
|
|
580
|
+
* (a bare append-failed row once hid a v4 turn-discipline rejection). */
|
|
581
|
+
function nudgeAppend(sessionId, text) {
|
|
582
|
+
const session = sessionOf(sessionId);
|
|
583
|
+
if (!surfaceCapable(session)) return { seq: null, error: null };
|
|
584
|
+
try {
|
|
585
|
+
const event = session.append("user/message", nudgeMessage(sessionId, text), { surfaceOp: "append" });
|
|
586
|
+
return { seq: Number.isFinite(event?.seq) ? event.seq : null, error: null };
|
|
587
|
+
} catch (error) {
|
|
588
|
+
return { seq: null, error: String(error?.message ?? error) };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** Prune advisory nudge nodes with point-sized compaction events. Only
|
|
592
|
+
* seqs still live on the surface are pruned: a fold may already have
|
|
593
|
+
* consumed one, and re-shadowing a vanished seq fails the v4 validator.
|
|
594
|
+
* Each seq gets its own prune event (start === end) so a non-contiguous
|
|
595
|
+
* set can never cover unlisted live turns — matching the contiguous
|
|
596
|
+
* range/seq pairing of the ladder and T1 passes. The metering prune is
|
|
597
|
+
* paired with its replacement (shadow-price protocol, mirroring the
|
|
598
|
+
* host's compaction-tool-result-pruner): the surfaceOp replace on the
|
|
599
|
+
* replacement is what actually retires the node from surface.nodes —
|
|
600
|
+
* a bare compaction/prune never moves the live surface, so without
|
|
601
|
+
* the pair the same nudge was re-found and re-pruned on EVERY boundary
|
|
602
|
+
* (one duplicate fold-marker event per step, 2026-11-14). */
|
|
603
|
+
/** Retired-nudge stub: id and text intentionally miss both nudgeScan
|
|
604
|
+
* signatures (id prefix "trim_nudge_", text prefix "[SWITCHMAN:TRIM]
|
|
605
|
+
* Advisory nudge") so a retired stub is never re-discovered. */
|
|
606
|
+
function retiredNudgeMessage(sessionId) {
|
|
607
|
+
const token = String(sessionId ?? "").replace(/[^A-Za-z0-9_-]/gu, "_").slice(0, 128) || "session";
|
|
608
|
+
return {
|
|
609
|
+
id: "trim_retired_nudge_" + token + "_" + (++nudgeCounter),
|
|
610
|
+
role: "user",
|
|
611
|
+
content: [{ type: "text", text: "[SWITCHMAN:TRIM] (advisory nudge retired — pressure is back below the fold line)" }],
|
|
612
|
+
source: { kind: "user" },
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function nudgePrune(sessionId, seqs) {
|
|
616
|
+
try {
|
|
617
|
+
const session = sessionOf(sessionId);
|
|
618
|
+
if (!surfaceCapable(session) || !Array.isArray(seqs)) return false;
|
|
619
|
+
const nodes = session.surface.nodes;
|
|
620
|
+
if (!Array.isArray(nodes)) return false;
|
|
621
|
+
const live = [...new Set(seqs.filter((seq) => Number.isFinite(seq)))].filter((seq) => nodes.includes(seq));
|
|
622
|
+
if (live.length === 0) return true; // already consumed by a fold: nothing to undo
|
|
623
|
+
if (live.includes(nodes[0])) {
|
|
624
|
+
let headEvent;
|
|
625
|
+
try {
|
|
626
|
+
headEvent = typeof session.eventAt === "function" ? session.eventAt(nodes[0]) : undefined;
|
|
627
|
+
} catch {
|
|
628
|
+
headEvent = undefined;
|
|
629
|
+
}
|
|
630
|
+
const headMessage = headEvent === undefined ? null : messageOfEvent(session, headEvent);
|
|
631
|
+
// Fail closed: an unreadable head node or the protected system head
|
|
632
|
+
// must never be shadowed ("compaction cannot shadow the protected
|
|
633
|
+
// system head").
|
|
634
|
+
if (headMessage === null || headMessage === undefined || headMessage.role === "system") return false;
|
|
635
|
+
}
|
|
636
|
+
for (const seq of live) {
|
|
637
|
+
const event = session.append("compaction/prune", {
|
|
638
|
+
shadowedRange: { start: seq, end: seq },
|
|
639
|
+
shadowedSeqs: [seq],
|
|
640
|
+
shadowedTokenCount: 1,
|
|
641
|
+
});
|
|
642
|
+
if (!Number.isFinite(event?.seq)) return false;
|
|
643
|
+
// Shadow-price pairing: the replacement — not the metering
|
|
644
|
+
// prune — retires the node from the live surface.
|
|
645
|
+
let retired;
|
|
646
|
+
try {
|
|
647
|
+
retired = session.append("user/message", retiredNudgeMessage(sessionId), {
|
|
648
|
+
surfaceOp: { op: "replace", startSeq: seq, endSeq: seq },
|
|
649
|
+
sourceEventSeqs: [seq],
|
|
650
|
+
});
|
|
651
|
+
} catch {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
if (!Number.isFinite(retired?.seq)) return false;
|
|
655
|
+
}
|
|
656
|
+
return true;
|
|
657
|
+
} catch {
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/** Find visible advisory nudges from the tail, bounded to recent surface nodes. */
|
|
662
|
+
function nudgeScan(sessionId) {
|
|
663
|
+
try {
|
|
664
|
+
const session = sessionOf(sessionId);
|
|
665
|
+
if (!surfaceCapable(session) || typeof session.eventAt !== "function") return [];
|
|
666
|
+
const nodes = session.surface.nodes;
|
|
667
|
+
const first = Math.max(0, nodes.length - 50);
|
|
668
|
+
const found = [];
|
|
669
|
+
for (let index = nodes.length - 1; index >= first; index--) {
|
|
670
|
+
const seq = nodes[index];
|
|
671
|
+
let event;
|
|
672
|
+
try {
|
|
673
|
+
event = session.eventAt(seq);
|
|
674
|
+
} catch {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const message = event === undefined ? null : messageOfEvent(session, event);
|
|
678
|
+
if (message === null || message === undefined) continue;
|
|
679
|
+
const hasNudgeId = typeof message.id === "string" && message.id.startsWith("trim_nudge_");
|
|
680
|
+
const hasNudgeText = nudgeTextOf(message).startsWith("[SWITCHMAN:TRIM] Advisory nudge");
|
|
681
|
+
if (hasNudgeId || hasNudgeText) found.push(seq);
|
|
682
|
+
}
|
|
683
|
+
return found;
|
|
684
|
+
} catch {
|
|
685
|
+
return [];
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
548
689
|
/** In-memory idempotence belt over the durable block marker: the same
|
|
549
690
|
* (session, range, contentGeneration) is never replaced twice even if a
|
|
550
691
|
* crash lost the ledger persist. Bounded like the other registries. */
|
|
@@ -612,6 +753,21 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
612
753
|
return mark !== null && typeof mark === "object" && typeof mark.reason === "string" && mark.reason !== "" ? mark : null;
|
|
613
754
|
}
|
|
614
755
|
|
|
756
|
+
/** Terminal supersede test: EVERY captured event seq has left the live
|
|
757
|
+
* surface, so the fold's material is already off the wire (host
|
|
758
|
+
* compaction consumed the range, or an earlier ship whose marker was
|
|
759
|
+
* later lost). Such a capture can never validate again —
|
|
760
|
+
* the block terminates as surfaceSuperseded instead of living forever
|
|
761
|
+
* in the generation-keyed incomplete-seqs retry loop. */
|
|
762
|
+
function captureFullyShadowed(nodes, block) {
|
|
763
|
+
// An unreadable/empty surface is fail-safe: nothing is proven gone,
|
|
764
|
+
// so never terminalize on it (a valid block would be lost forever).
|
|
765
|
+
if (!Array.isArray(nodes) || nodes.length === 0) return false;
|
|
766
|
+
if (!Array.isArray(block?.surfaceSeqs) || block.surfaceSeqs.length === 0) return false;
|
|
767
|
+
const live = new Set(nodes);
|
|
768
|
+
return block.surfaceSeqs.every((seq) => !live.has(seq));
|
|
769
|
+
}
|
|
770
|
+
|
|
615
771
|
/** One no-op row (only when the pass had pending work — a vacuous sweep
|
|
616
772
|
* with nothing to ship stays silent, matching the early-return style of
|
|
617
773
|
* the fold cycle's own action=none rows). Whole-pass reasons (disabled /
|
|
@@ -657,6 +813,8 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
657
813
|
})();
|
|
658
814
|
const pending = blocks.filter((block) => {
|
|
659
815
|
if (block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined) return false;
|
|
816
|
+
// Terminal supersede (see captureFullyShadowed): never pending again.
|
|
817
|
+
if (block?.surfaceSuperseded !== null && block?.surfaceSuperseded !== undefined) return false;
|
|
660
818
|
const tomb = tombstoneOf(block);
|
|
661
819
|
if (tomb === null) return true;
|
|
662
820
|
// Re-open only when the surface content changed since the skip.
|
|
@@ -720,6 +878,21 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
720
878
|
}
|
|
721
879
|
const waiting = consumed.find((entry) => entry.surfaceReplaced === null || entry.surfaceReplaced === undefined);
|
|
722
880
|
if (waiting !== undefined) {
|
|
881
|
+
if (waiting?.surfaceSuperseded !== null && waiting?.surfaceSuperseded !== undefined) {
|
|
882
|
+
// The consumed block's material left the surface entirely: its
|
|
883
|
+
// replacement node can never materialize, so the distillation
|
|
884
|
+
// is terminal too (same proof as the T1 supersede — no
|
|
885
|
+
// generation-keyed retry can revive either block).
|
|
886
|
+
block.surfaceSuperseded = { reason: "ladder-consumed-superseded", at: Date.now() };
|
|
887
|
+
delete block.surfaceSkip;
|
|
888
|
+
try {
|
|
889
|
+
store.persist(ledger);
|
|
890
|
+
} catch {
|
|
891
|
+
/* best effort */
|
|
892
|
+
}
|
|
893
|
+
skip(sessionId, "superseded-ladder-consumed", via, `block=${block.id} consumed=${waiting.id}`, false);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
723
896
|
if (tombstoneOf(waiting) !== null) {
|
|
724
897
|
// A consumed block is hard-skipped on this generation: its
|
|
725
898
|
// replacement node can never materialize, so the ladder span
|
|
@@ -753,7 +926,7 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
753
926
|
const idIndexOf = new Map();
|
|
754
927
|
for (let i = 0; i < nodeMessages.length; i++) {
|
|
755
928
|
const message = nodeMessages[i];
|
|
756
|
-
if (message
|
|
929
|
+
if (isFoldSummaryMessage(message)) {
|
|
757
930
|
idIndexOf.set(message.id, i);
|
|
758
931
|
}
|
|
759
932
|
}
|
|
@@ -765,7 +938,7 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
765
938
|
atSeqs.push(atSeq);
|
|
766
939
|
continue;
|
|
767
940
|
}
|
|
768
|
-
const relocatedIdx = idIndexOf.get(
|
|
941
|
+
const relocatedIdx = idIndexOf.get(`${FOLD_SUMMARY_ID_PREFIX}${entry.id}`);
|
|
769
942
|
if (relocatedIdx === undefined) {
|
|
770
943
|
unresolved = entry;
|
|
771
944
|
break;
|
|
@@ -795,7 +968,7 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
795
968
|
// picked up a stranger's acp_summary node is not this fold's
|
|
796
969
|
// material (the length check alone cannot tell them apart).
|
|
797
970
|
if (spanExact) {
|
|
798
|
-
|
|
971
|
+
const expectedIds = new Set(consumed.map((entry) => `${FOLD_SUMMARY_ID_PREFIX}${entry.id}`));
|
|
799
972
|
for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
|
|
800
973
|
const message = nodeMessages[i];
|
|
801
974
|
if (message === null || message === undefined || typeof message.id !== "string" || !expectedIds.has(message.id)) {
|
|
@@ -807,7 +980,7 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
807
980
|
if (spanExact) {
|
|
808
981
|
for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
|
|
809
982
|
const message = nodeMessages[i];
|
|
810
|
-
|
|
983
|
+
if (!isFoldSummaryMessage(message)) {
|
|
811
984
|
spanExact = false;
|
|
812
985
|
break;
|
|
813
986
|
}
|
|
@@ -906,6 +1079,23 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
906
1079
|
const located = locateOnSurface(pairs, block);
|
|
907
1080
|
if (located === null) {
|
|
908
1081
|
if (Array.isArray(block.surfaceSeqs)) {
|
|
1082
|
+
if (captureFullyShadowed(nodes, block)) {
|
|
1083
|
+
// Every captured seq has left the live surface: the range was
|
|
1084
|
+
// already shadowed wholesale (host compaction, a prior ship
|
|
1085
|
+
// whose marker was later lost). The material is
|
|
1086
|
+
// off the wire and this capture can never validate again —
|
|
1087
|
+
// terminate the block instead of re-tombstoning it on every
|
|
1088
|
+
// generation bump (the pending filter drops it for good).
|
|
1089
|
+
block.surfaceSuperseded = { reason: "shadowed", at: Date.now() };
|
|
1090
|
+
delete block.surfaceSkip;
|
|
1091
|
+
try {
|
|
1092
|
+
store.persist(ledger);
|
|
1093
|
+
} catch {
|
|
1094
|
+
/* best effort */
|
|
1095
|
+
}
|
|
1096
|
+
skip(sessionId, "superseded-shadowed", via, `block=${block.id}`, false);
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
909
1099
|
block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
|
|
910
1100
|
skip(sessionId, "incomplete-seqs", via, `block=${block.id}`, false);
|
|
911
1101
|
} else {
|
|
@@ -930,6 +1120,23 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
930
1120
|
&& nodes[located[0]] === storedFirst
|
|
931
1121
|
&& nodes[located[1]] === storedLast;
|
|
932
1122
|
if (!endpointsMatch) {
|
|
1123
|
+
if (captureFullyShadowed(nodes, block)) {
|
|
1124
|
+
// Every captured seq has left the live surface: the range was
|
|
1125
|
+
// already shadowed wholesale (host compaction, a prior ship
|
|
1126
|
+
// whose marker was later lost). The material is
|
|
1127
|
+
// off the wire and this capture can never validate again —
|
|
1128
|
+
// terminate the block instead of re-tombstoning it on every
|
|
1129
|
+
// generation bump (the pending filter drops it for good).
|
|
1130
|
+
block.surfaceSuperseded = { reason: "shadowed", at: Date.now() };
|
|
1131
|
+
delete block.surfaceSkip;
|
|
1132
|
+
try {
|
|
1133
|
+
store.persist(ledger);
|
|
1134
|
+
} catch {
|
|
1135
|
+
/* best effort */
|
|
1136
|
+
}
|
|
1137
|
+
skip(sessionId, "superseded-shadowed", via, `block=${block.id} relocated-range`, false);
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
933
1140
|
block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
|
|
934
1141
|
skip(sessionId, "incomplete-seqs", via, `block=${block.id} relocated-range`, false);
|
|
935
1142
|
continue;
|
|
@@ -1127,7 +1334,11 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
1127
1334
|
const sessionId = sessionIdOfAgent(payload?.agent);
|
|
1128
1335
|
if (sessionId === null) return;
|
|
1129
1336
|
const hint = payload?.agent !== null && typeof payload?.agent === "object" ? payload.agent.session : null;
|
|
1130
|
-
void sweep(sessionId, "turn-stopping", hint)
|
|
1337
|
+
void sweep(sessionId, "turn-stopping", hint).catch(() => {
|
|
1338
|
+
/* sweep is fire-and-forget on this boundary; a rejection must not
|
|
1339
|
+
surface as an unhandledRejection (the listener's own try only
|
|
1340
|
+
covers synchronous throws — async rejects bypass it). */
|
|
1341
|
+
});
|
|
1131
1342
|
} catch {
|
|
1132
1343
|
/* listener must never throw */
|
|
1133
1344
|
}
|
|
@@ -1136,5 +1347,5 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
1136
1347
|
warn(`[context-trim] agent/turn-stopping hook failed: ${error?.message ?? error}`);
|
|
1137
1348
|
}
|
|
1138
1349
|
|
|
1139
|
-
return { sweep, captureFoldSeqs, enabledNow, supported };
|
|
1350
|
+
return { sweep, captureFoldSeqs, enabledNow, supported, nudgeSupported, nudgeAppend, nudgePrune, nudgeScan };
|
|
1140
1351
|
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* which keeps the decision unit-testable (golden thresholds).
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { estimateTokensOf, refPositions, segmentBounds } from "./view.js";
|
|
35
|
+
import { estimateTokensOf, isFoldSummaryMessage, refPositions, segmentBounds } from "./view.js";
|
|
36
36
|
|
|
37
37
|
/** Hard minimum wall-clock interval between side-model folds. */
|
|
38
38
|
export const FOLD_MIN_INTERVAL_MS = 60_000;
|
|
@@ -50,10 +50,16 @@ export const LADDER_MIN_INTERVAL_MS = 120_000;
|
|
|
50
50
|
* first segment boundary strictly after the frontier AND after the task
|
|
51
51
|
* intent (the first user message — bili's protected zone: the session's
|
|
52
52
|
* opening task statement is never folded) to the last segment end that
|
|
53
|
-
* still clears the protected tail.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
53
|
+
* still clears the protected tail. Prior fold summaries (acp_summary_*,
|
|
54
|
+
* which the surface pass relocates right after the intent segment) are
|
|
55
|
+
* NEVER inside the range: they stay live on the surface and consolidate
|
|
56
|
+
* only through ladder T2/T3 distillation. This exclusion is load-bearing —
|
|
57
|
+
* the frontier message is itself shadowed once its fold ships, so without
|
|
58
|
+
* it the fallback start slid onto the last summary and every later fold
|
|
59
|
+
* re-absorbed the previous summary (the rolling-absorb regression).
|
|
60
|
+
* Returns { startIdx, endIdx } or null when nothing is foldable (empty
|
|
61
|
+
* history, everything folded, or protected tail covers it all). */
|
|
62
|
+
export function computeFoldableRange(messages, ledger, protectedTail, options = {}) {
|
|
57
63
|
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
58
64
|
const tail = Number.isInteger(protectedTail) && protectedTail > 0 ? protectedTail : 0;
|
|
59
65
|
const tailStart = messages.length - tail;
|
|
@@ -75,19 +81,30 @@ export function computeFoldableRange(messages, ledger, protectedTail) {
|
|
|
75
81
|
// Task-intent protection: the first user message states the task; the
|
|
76
82
|
// segment containing it is never folded (its summary would become the
|
|
77
83
|
// only carrier of the task for the rest of the session — the dominant
|
|
78
|
-
// early-drift failure this guard prevents).
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
// early-drift failure this guard prevents). Opt-out: trimProtectTaskIntent
|
|
85
|
+
// =false lets the intent segment fold like any other raw content; the
|
|
86
|
+
// system-head clamp above is unaffected — it applies only when the head is actually a system message.
|
|
87
|
+
if (options?.protectTaskIntent !== false) {
|
|
88
|
+
const firstUserIdx = messages.findIndex((message) => message?.role === "user");
|
|
89
|
+
if (firstUserIdx >= 0) {
|
|
90
|
+
for (const [start, end] of segmentBounds(messages)) {
|
|
91
|
+
if (start <= firstUserIdx && firstUserIdx <= end) {
|
|
92
|
+
startFrom = Math.max(startFrom, end + 1);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
98
|
let best = null;
|
|
89
99
|
for (const [start, end] of segmentBounds(messages)) {
|
|
90
100
|
if (start < startFrom) continue; // behind the frontier / intent
|
|
101
|
+
if (isFoldSummaryMessage(messages[start])) {
|
|
102
|
+
// A summary is its own segment (plain user message, no tool pair).
|
|
103
|
+
// Ahead of the first raw segment: skip the summary run. After raw
|
|
104
|
+
// segments have begun: stop — never bridge a span across one.
|
|
105
|
+
if (best !== null) break;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
91
108
|
if (end >= tailStart) break; // segment would cut into the protected tail
|
|
92
109
|
if (best === null) best = { startIdx: start, endIdx: end };
|
|
93
110
|
else best.endIdx = end; // extend the contiguous span
|
|
@@ -250,18 +250,33 @@ export function renderSummaryText(block) {
|
|
|
250
250
|
return `[SWITCHMAN:TRIM] ${block.summary}`;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/** Wire-id prefix of every fold-summary synthetic message. The single
|
|
254
|
+
* definition: summaryMessageOf builds ids from it, isFoldSummaryMessage
|
|
255
|
+
* recognizes them, and ladder relocation re-derives the same ids. */
|
|
256
|
+
export const FOLD_SUMMARY_ID_PREFIX = "acp_summary_";
|
|
257
|
+
|
|
253
258
|
/** The ONE synthetic message replacing a folded range (brief: synthetic
|
|
254
259
|
* user message; id carries the acp_summary_ prefix downstream tooling
|
|
255
260
|
* skips). Pure — same block, same bytes. The ladder replaces messages BY
|
|
256
261
|
* this id (id-suffix = block id), which is why ids must stay stable. */
|
|
257
262
|
export function summaryMessageOf(block) {
|
|
258
263
|
return {
|
|
259
|
-
id:
|
|
264
|
+
id: `${FOLD_SUMMARY_ID_PREFIX}${block.id}`,
|
|
260
265
|
role: "user",
|
|
261
266
|
content: [{ type: "text", text: renderSummaryText(block) }],
|
|
262
267
|
};
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
/** True when one wire message is a prior fold's synthetic summary
|
|
271
|
+
* (acp_summary_<blockId>). Recognition rides the id prefix only — never
|
|
272
|
+
* the source kind (the v4 validator rejects compact-checkpoint sources
|
|
273
|
+
* outside a compaction/start..end pair). Range selection and the advisory
|
|
274
|
+
* fold tool use this to KEEP summaries out of new raw folds: summaries
|
|
275
|
+
* stay live on the surface and consolidate only through ladder T2/T3. */
|
|
276
|
+
export function isFoldSummaryMessage(message) {
|
|
277
|
+
return typeof message?.id === "string" && message.id.startsWith(FOLD_SUMMARY_ID_PREFIX);
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
/** Locate a block's range in a positions map at/after afterIdx. Strict:
|
|
266
281
|
* both endpoint refs must resolve and the located span must equal the
|
|
267
282
|
* block's stored message count (guards against re-sent histories that no
|
|
@@ -310,7 +325,7 @@ function locateBlock(positions, block, afterIdx) {
|
|
|
310
325
|
* -> index, for every synthetic message. One index per id (a block renders
|
|
311
326
|
* at most once — single consumption). */
|
|
312
327
|
function summaryPositionsOf(messages) {
|
|
313
|
-
const prefix =
|
|
328
|
+
const prefix = FOLD_SUMMARY_ID_PREFIX;
|
|
314
329
|
const positions = new Map();
|
|
315
330
|
messages.forEach((message, index) => {
|
|
316
331
|
const id = message?.id;
|
package/host/context-watch.js
CHANGED
|
@@ -136,8 +136,8 @@ function isRoot(ctx, agent) {
|
|
|
136
136
|
function readThresholds(config) {
|
|
137
137
|
return {
|
|
138
138
|
soft: readNumber(config.wmSoftTokens, 80000),
|
|
139
|
-
hard: readNumber(config.wmHardTokens,
|
|
140
|
-
force: readNumber(config.wmForceTokens,
|
|
139
|
+
hard: readNumber(config.wmHardTokens, 100000),
|
|
140
|
+
force: readNumber(config.wmForceTokens, 200000),
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -154,8 +154,9 @@ export function measureNow(ctx, agent) {
|
|
|
154
154
|
// longer receives — high bias), while the bare view never counts the
|
|
155
155
|
// system/tool sections (low bias). max(view, wire) fixes both and puts
|
|
156
156
|
// the watermark tiers (soft/hard/force + read budget) on the SAME
|
|
157
|
-
// boundary as the fold engine — wmHardTokens
|
|
158
|
-
//
|
|
157
|
+
// boundary as the fold engine — wmHardTokens 100k = trimAdvisoryPercent
|
|
158
|
+
// 50% × the 200k cap, and wmForceTokens 200k = the cap itself — holds
|
|
159
|
+
// in units, not just in
|
|
159
160
|
// numbers. Strictly additive +
|
|
160
161
|
// fail-open: any trim failure falls back to the stored measure.
|
|
161
162
|
try {
|
|
@@ -207,7 +208,11 @@ const PAUSED_LINE =
|
|
|
207
208
|
function renderRootWatermark(level, status) {
|
|
208
209
|
if (level === "ok") return "";
|
|
209
210
|
if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.";
|
|
210
|
-
if (level === "hard")
|
|
211
|
+
if (level === "hard")
|
|
212
|
+
return (
|
|
213
|
+
"[SWITCHMAN:WATERMARK] hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up. " +
|
|
214
|
+
"If a fold or handover was committed within the last turn, this tier may reflect pre-fold pressure — the next turn carries the reduced history; do not re-verify with broad reads."
|
|
215
|
+
);
|
|
211
216
|
const lines = ["[SWITCHMAN:WATERMARK] force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
212
217
|
const phase = status?.phase;
|
|
213
218
|
if (phase === "backup") {
|
|
@@ -276,7 +281,11 @@ function subagentWindowTokens(ctx, state, sessionId) {
|
|
|
276
281
|
async function primeSubagentWindow(ctx, state, route, key) {
|
|
277
282
|
try {
|
|
278
283
|
const llm = ctx.get?.("llm", false);
|
|
279
|
-
|
|
284
|
+
// Host contract: resolveModelInfo(provider, model) takes TWO strings. The
|
|
285
|
+
// object shape threw NO_ADAPTER and subagent window scaling silently fell
|
|
286
|
+
// back to static tiers (found 2026-11-14; same contract every other
|
|
287
|
+
// call site already follows).
|
|
288
|
+
const info = await llm?.resolveModelInfo?.(route.provider, route.model);
|
|
280
289
|
const windowTokens = info?.context?.contextWindow;
|
|
281
290
|
const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
|
|
282
291
|
state.subWindow.set(key, { value, at: Date.now() });
|
|
@@ -900,7 +909,7 @@ function applyContextWatch(ctx, config) {
|
|
|
900
909
|
if (sessionId !== null && ACCOUNT_TOOLS.has(exec?.name ?? "")) {
|
|
901
910
|
const entry = accountRead(state, sessionId, resultTokens(result));
|
|
902
911
|
if (GATED_TOOLS.has(exec.name) && !state.paused.has(sessionId)) {
|
|
903
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
912
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3500);
|
|
904
913
|
const ceiling = budget * CEILING_MULTIPLE;
|
|
905
914
|
const used = measureNow(ctx, exec.agent);
|
|
906
915
|
const th = readThresholds(config);
|
|
@@ -946,7 +955,7 @@ function applyContextWatch(ctx, config) {
|
|
|
946
955
|
if (!GATED_TOOLS.has(exec?.name ?? "")) return next();
|
|
947
956
|
const sessionId = sessionIdOf(exec?.agent);
|
|
948
957
|
if (sessionId === null || state.paused.has(sessionId)) return next();
|
|
949
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
958
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3500);
|
|
950
959
|
if (turnUsed(state, sessionId) < budget * CEILING_MULTIPLE) return next();
|
|
951
960
|
const used = measureNow(ctx, exec.agent);
|
|
952
961
|
if (used === null) return next();
|
package/host/intent.js
CHANGED
|
@@ -144,9 +144,9 @@ function routeOf(value) {
|
|
|
144
144
|
* handover off the user's raw window is respected as-is — they opted out
|
|
145
145
|
* of the handover pipeline, so the 0.8 line may sit wherever they put it. */
|
|
146
146
|
export function effectiveContextWindow(config) {
|
|
147
|
-
const user = Math.max(1024, Math.round(readNumber(config.intentContextWindow,
|
|
147
|
+
const user = Math.max(1024, Math.round(readNumber(config.intentContextWindow, 250000)));
|
|
148
148
|
if (!readBool(config.wmAutoHandover)) return user;
|
|
149
|
-
const floor = Math.ceil(readNumber(config.wmForceTokens,
|
|
149
|
+
const floor = Math.ceil(readNumber(config.wmForceTokens, 200000) / 0.8);
|
|
150
150
|
return Math.max(user, floor);
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -553,6 +553,38 @@ async function classifyOnce(ctx, config, route, system, input, turnSignal) {
|
|
|
553
553
|
}
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
/** Whether a classifier endpoint URL may receive conversation excerpts.
|
|
557
|
+
* The systemone channel is meant for a LOCAL/LAN decision endpoint
|
|
558
|
+
* (ollama); an arbitrary URL would turn the setting into an
|
|
559
|
+
* exfiltration channel — any settings writer could aim the per-turn
|
|
560
|
+
* excerpt (recent conversation text) at a public server. Allowed:
|
|
561
|
+
* http/https URLs whose host is loopback (localhost, 127/8, ::1) or
|
|
562
|
+
* private-network (RFC1918 10/8, 172.16/12, 192.168/16, link-local
|
|
563
|
+
* 169.254/16, fe80::/10, unique-local fc00::/7). Everything else is
|
|
564
|
+
* rejected before any byte leaves the process; the classifier then
|
|
565
|
+
* fails open to the fallback chain. Exported for tests. */
|
|
566
|
+
export function isAllowedClassifierEndpoint(endpoint) {
|
|
567
|
+
if (typeof endpoint !== "string") return false;
|
|
568
|
+
let url;
|
|
569
|
+
try {
|
|
570
|
+
url = new URL(endpoint.trim());
|
|
571
|
+
} catch {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
575
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
576
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
|
577
|
+
const parts = host.split(".");
|
|
578
|
+
if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/u.test(p))) {
|
|
579
|
+
const octets = parts.map(Number);
|
|
580
|
+
if (octets.some((n) => n > 255)) return false;
|
|
581
|
+
const [a, b] = octets;
|
|
582
|
+
return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
|
|
583
|
+
}
|
|
584
|
+
// IPv6 (brackets stripped): loopback, link-local fe80::/10, unique-local fc00::/7.
|
|
585
|
+
return host === "::1" || host.startsWith("fe80:") || /^[fF][cCdD][0-9a-fA-F]{2}:/.test(host);
|
|
586
|
+
}
|
|
587
|
+
|
|
556
588
|
/**
|
|
557
589
|
* Classify via an ollama /v1/systemone decision endpoint (direct HTTP —
|
|
558
590
|
* no llm-service provider registration needed; the model name comes from
|
|
@@ -567,6 +599,8 @@ async function classifyOnce(ctx, config, route, system, input, turnSignal) {
|
|
|
567
599
|
export async function classifySystemone(ctx, config, candidates, current, excerpt, turnSignal) {
|
|
568
600
|
const endpoint = readField(config.intentClassifierEndpoint).trim();
|
|
569
601
|
if (endpoint === "") throw new Error("systemone endpoint unset (intentClassifierEndpoint)");
|
|
602
|
+
if (!isAllowedClassifierEndpoint(endpoint))
|
|
603
|
+
throw new Error(`systemone endpoint refused (loopback/private URLs only): ${endpoint}`);
|
|
570
604
|
const model = readField(config.intentClassifierSystemoneModel).trim();
|
|
571
605
|
if (model === "") throw new Error("systemone model unset (intentClassifierSystemoneModel)");
|
|
572
606
|
if (candidates.length === 0) throw new Error("no candidates");
|
|
@@ -603,6 +637,9 @@ export async function classifySystemone(ctx, config, candidates, current, excerp
|
|
|
603
637
|
const signal = turnSignal instanceof AbortSignal ? AbortSignal.any([controller.signal, turnSignal]) : controller.signal;
|
|
604
638
|
try {
|
|
605
639
|
signal.throwIfAborted();
|
|
640
|
+
// Auditable egress record: the classifier ships a conversation excerpt
|
|
641
|
+
// off-process — every send is logged with the destination host.
|
|
642
|
+
ctx?.logger?.info?.(`dsh-switchman: intent systemone classifier POST ${new URL(endpoint).host} (excerpt ~${excerpt.length} chars)`);
|
|
606
643
|
const response = await fetch(endpoint, {
|
|
607
644
|
method: "POST",
|
|
608
645
|
headers: { "Content-Type": "application/json" },
|
|
@@ -766,7 +803,7 @@ export async function intentStateOf(ctx, config, sessionId = null) {
|
|
|
766
803
|
}
|
|
767
804
|
}
|
|
768
805
|
}
|
|
769
|
-
const forceTokens = readNumber(config.wmForceTokens,
|
|
806
|
+
const forceTokens = readNumber(config.wmForceTokens, 200000);
|
|
770
807
|
return {
|
|
771
808
|
enabled,
|
|
772
809
|
autoSelected: selection?.provider === AUTO_PROVIDER,
|
|
@@ -774,7 +811,7 @@ export async function intentStateOf(ctx, config, sessionId = null) {
|
|
|
774
811
|
// catalog route must not mark the feature unconfigured.
|
|
775
812
|
configured:
|
|
776
813
|
(kind === "systemone"
|
|
777
|
-
? readField(config.intentClassifierEndpoint).trim()
|
|
814
|
+
? isAllowedClassifierEndpoint(readField(config.intentClassifierEndpoint).trim()) &&
|
|
778
815
|
readField(config.intentClassifierSystemoneModel).trim() !== ""
|
|
779
816
|
: classifier !== null) &&
|
|
780
817
|
candidates.length > 0,
|
package/host/lang.js
CHANGED
|
@@ -55,10 +55,12 @@ const PROJECT_FILE = "lang.json";
|
|
|
55
55
|
/** How long a cached settings-describe locale stays fresh (ms). */
|
|
56
56
|
const LOCALE_CACHE_MS = 10_000;
|
|
57
57
|
|
|
58
|
-
/** Normalize one language value: trim
|
|
58
|
+
/** Normalize one language value: trim, collapse internal whitespace (newlines
|
|
59
|
+
* would break the single-line [SWITCHMAN:LANG] anchor and could smuggle extra
|
|
60
|
+
* protocol lines into the system prompt), bound the length; anything else → null. */
|
|
59
61
|
function normalizeLangValue(value) {
|
|
60
62
|
if (typeof value !== "string") return null;
|
|
61
|
-
const trimmed = value.trim();
|
|
63
|
+
const trimmed = value.trim().replace(/\s+/gu, " ");
|
|
62
64
|
if (trimmed.length === 0 || trimmed.length > MAX_LANG_LEN) return null;
|
|
63
65
|
return trimmed;
|
|
64
66
|
}
|