dsh-switchman 1.11.6 → 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 -31
- package/README.es.md +36 -31
- package/README.fr.md +36 -31
- package/README.it.md +36 -31
- package/README.ja.md +36 -31
- package/README.ko.md +36 -31
- package/README.md +37 -32
- package/README.pt.md +36 -31
- package/README.ru.md +36 -31
- package/README.zh-TW.md +36 -31
- package/README.zh.md +36 -31
- package/client.js +355 -26
- package/cordis.patch.yml +10 -10
- package/host/config.js +64 -33
- package/host/context-trim/index.js +539 -60
- package/host/context-trim/state.js +3 -2
- package/host/context-trim/surface.js +5 -1
- package/host/context-watch.js +12 -7
- 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 +110 -34
- 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
|
|
@@ -1334,7 +1334,11 @@ export function createSurfaceFoldPass(deps = {}) {
|
|
|
1334
1334
|
const sessionId = sessionIdOfAgent(payload?.agent);
|
|
1335
1335
|
if (sessionId === null) return;
|
|
1336
1336
|
const hint = payload?.agent !== null && typeof payload?.agent === "object" ? payload.agent.session : null;
|
|
1337
|
-
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
|
+
});
|
|
1338
1342
|
} catch {
|
|
1339
1343
|
/* listener must never throw */
|
|
1340
1344
|
}
|
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") {
|
|
@@ -904,7 +909,7 @@ function applyContextWatch(ctx, config) {
|
|
|
904
909
|
if (sessionId !== null && ACCOUNT_TOOLS.has(exec?.name ?? "")) {
|
|
905
910
|
const entry = accountRead(state, sessionId, resultTokens(result));
|
|
906
911
|
if (GATED_TOOLS.has(exec.name) && !state.paused.has(sessionId)) {
|
|
907
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
912
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3500);
|
|
908
913
|
const ceiling = budget * CEILING_MULTIPLE;
|
|
909
914
|
const used = measureNow(ctx, exec.agent);
|
|
910
915
|
const th = readThresholds(config);
|
|
@@ -950,7 +955,7 @@ function applyContextWatch(ctx, config) {
|
|
|
950
955
|
if (!GATED_TOOLS.has(exec?.name ?? "")) return next();
|
|
951
956
|
const sessionId = sessionIdOf(exec?.agent);
|
|
952
957
|
if (sessionId === null || state.paused.has(sessionId)) return next();
|
|
953
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
958
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3500);
|
|
954
959
|
if (turnUsed(state, sessionId) < budget * CEILING_MULTIPLE) return next();
|
|
955
960
|
const used = measureNow(ctx, exec.agent);
|
|
956
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
|
}
|