@kontextmind/kxm 0.7.158 → 0.7.160
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +4 -16
- package/docs/contracts/lifecycles.md +22 -11
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +3 -0
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +94 -30
- package/plugins/kxm/dist/runtime.js +94 -30
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/oneshot-producer.ts +156 -46
package/CHANGELOG.md
CHANGED
|
@@ -414,22 +414,14 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
414
414
|
reply for any declared outcome *word* and, failing that, returned `passed`. So
|
|
415
415
|
`"the gate did not pass, so I would not call this passed"` settled the step as passed — the
|
|
416
416
|
word was all it took — and an empty or prose-only reply was passed by default. Only a declared
|
|
417
|
-
result counts now
|
|
418
|
-
`{"outcome": "…"}` block, and only when the step declares that outcome. Anything else is
|
|
417
|
+
result counts now, and only when the step declares that outcome. Anything else is
|
|
419
418
|
`failed`; an undeclared value still lands in `outcome_unknown` and terminates as `failed`, so a
|
|
420
419
|
step without a `failed` transition cannot pass on a bad reply either. This matches the rule the
|
|
421
420
|
one-shot producer already enforced, and `docs/contracts/lifecycles.md` now states it where
|
|
422
421
|
`result_recorded` is defined. Three usage-capture fixtures that had been replying in prose now
|
|
423
422
|
declare their result, which is what they were always supposed to do; the new test in
|
|
424
|
-
`test/core/pi-producer.test.ts` covers prose, empty, out-of-vocabulary
|
|
425
|
-
|
|
426
|
-
closed: a result block was matched **anywhere** in the reply, so
|
|
427
|
-
`Example: {"outcome": "passed"}. Actual result: {"outcome": "failed"}` returned `passed`;
|
|
428
|
-
a declaration is now a standalone JSON object — the whole reply, or one object on its own
|
|
429
|
-
line, with the **last** such object winning so an illustration cannot outrank the answer, and
|
|
430
|
-
the whole reply settling `failed` when anything after that line still looks like an outcome
|
|
431
|
-
key, because at that point the producer cannot tell which declaration was meant.
|
|
432
|
-
And cancellation fell through to `allowedOutcomes[0]` when a step declared neither
|
|
423
|
+
`test/core/pi-producer.test.ts` covers prose, empty, and out-of-vocabulary replies.
|
|
424
|
+
Cancellation fell through to `allowedOutcomes[0]` when a step declared neither
|
|
433
425
|
`cancelled` nor `failed`, so aborting a `passed`-only step reported `passed`; a cancel now
|
|
434
426
|
reports `cancelled` unconditionally and the engine terminates it `failed` when the step
|
|
435
427
|
does not declare that outcome.
|
|
@@ -572,9 +564,7 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
572
564
|
|
|
573
565
|
### Fixed
|
|
574
566
|
|
|
575
|
-
- **A one-shot outcome is the JSON object that ends the reply.**
|
|
576
|
-
|
|
577
|
-
- **A committed checkout counts as authored work.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`.
|
|
567
|
+
- **A one-shot outcome is the last top-level JSON object that ends the reply.** It is accepted only when the prefix before it leaves every string, object, and array closed (a naive state machine plus a container lexer over the last container openers). Anything ambiguous, truncated, or followed by prose fails closed. The authoring witness counts `HEAD`, so a writer that commits is credited.
|
|
578
568
|
|
|
579
569
|
- **The test suite no longer passes `--test-timeout`.** Under `node --test` that flag bounds each file, so coverage on CI timed out `test/core/engine.test.ts` at three minutes. The wall clock in `scripts/run-bounded.mjs` still bounds each script.
|
|
580
570
|
|
|
@@ -892,8 +882,6 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
892
882
|
|
|
893
883
|
- **Dry-run ship status no longer rewrites `.git/index`.** `git status` refreshes the index under an optional lock. The ship-status read passes `--no-optional-locks`, so a dry run leaves the checkout untouched.
|
|
894
884
|
|
|
895
|
-
- **A committed checkout counts as authored work, and a one-shot outcome must be a standalone JSON object.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`. A one-shot outcome is accepted when the whole reply is one JSON object, or when that object stands alone on the final line. A closing code fence around the final object is allowed. An object followed by prose, a truncated reply, and an ambiguous tail settle `failed`.
|
|
896
|
-
|
|
897
885
|
## 0.7.0 - 2026-09-11
|
|
898
886
|
|
|
899
887
|
### Added
|
|
@@ -106,17 +106,28 @@ created → accepted → dispatched → executing → result_recorded → termin
|
|
|
106
106
|
| `terminal` | The logical assignment outcome is final and immutable: passed, failed, or cancelled |
|
|
107
107
|
|
|
108
108
|
A producer reply becomes a terminal outcome **only** through a declared result. A one-shot
|
|
109
|
-
reply declares that result when
|
|
110
|
-
end of the trimmed text.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
whitespace. The object may be the whole reply, a fenced reply,
|
|
114
|
-
prose, on one line or several. `JSON.parse`
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
reply declares that result when a candidate slice from a `{` to a `}` parses as a JSON
|
|
110
|
+
object that closes at the end of the trimmed text. Candidates are tried nearest last.
|
|
111
|
+
The walk considers at most the last 32 closers and the last 32 openers, skips a slice
|
|
112
|
+
whose UTF-8 size is over 256 KiB, and stops after 1 MiB of UTF-8 bytes from parsed
|
|
113
|
+
slices. One closing code fence may follow the object, with whitespace. The object may be the whole reply, a fenced reply,
|
|
114
|
+
or the end of surrounding prose, on one line or several. `JSON.parse` is the authority
|
|
115
|
+
for strings and escapes inside the slice. The value must be a plain object whose
|
|
116
|
+
`outcome` string is one of the step's declared values. The prefix before that opener,
|
|
117
|
+
with trailing whitespace removed, is scanned by a naive JSON lexer: an unescaped quote
|
|
118
|
+
toggles string state, a backslash escapes the next character inside a string, and `{`
|
|
119
|
+
and `[` track depth. Extra `}` and `]` do not drive depth below zero. The candidate is
|
|
120
|
+
accepted only when that scan ends at depth 0 for both braces and arrays, outside a
|
|
121
|
+
string, with no dangling escape. Any other end state is truncated or ambiguous, so
|
|
122
|
+
that candidate is rejected and the next one is tried. The naive machine plus a
|
|
123
|
+
container-lexer guard reject truncation. For each of the last 16 `{` or `[`
|
|
124
|
+
openers in the prefix, a lexer from that opener through the end of the body
|
|
125
|
+
rejects the candidate when the scan stays valid and ends inside a container or
|
|
126
|
+
a string. A quote is not an opener. Prose with an odd quote count fails closed.
|
|
127
|
+
Quotes, backticks, and balanced braces in the prose do not hide an
|
|
128
|
+
object that ends the reply. Prose after the object, a reply with no such object, a
|
|
129
|
+
disallowed outcome, an inner object at the end of a truncated outer object or array,
|
|
130
|
+
and an object inside an unclosed string settle `failed`.
|
|
120
131
|
|
|
121
132
|
Naming an outcome *word* anywhere in a reply is not a result. `"the gate did not pass, so I
|
|
122
133
|
would not call this passed"` must not advance a step, and an empty or unstructured reply is
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "kxm",
|
|
4
4
|
"displayName": "KXM",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.160",
|
|
6
6
|
"description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "KontextMind",
|
package/plugins/kxm/dist/cli.js
CHANGED
|
@@ -27105,6 +27105,7 @@ var init_engine = __esm({
|
|
|
27105
27105
|
});
|
|
27106
27106
|
|
|
27107
27107
|
// plugins/kxm/src/oneshot-producer.ts
|
|
27108
|
+
var MAX_OUTCOME_SLICE_BYTES, MAX_OUTCOME_PARSE_BYTES;
|
|
27108
27109
|
var init_oneshot_producer = __esm({
|
|
27109
27110
|
"plugins/kxm/src/oneshot-producer.ts"() {
|
|
27110
27111
|
"use strict";
|
|
@@ -27114,6 +27115,8 @@ var init_oneshot_producer = __esm({
|
|
|
27114
27115
|
init_harness();
|
|
27115
27116
|
init_prices();
|
|
27116
27117
|
init_engine();
|
|
27118
|
+
MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
|
|
27119
|
+
MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
|
|
27117
27120
|
}
|
|
27118
27121
|
});
|
|
27119
27122
|
|
|
@@ -17313,7 +17313,7 @@ function sessionTokenFixHint(policy) {
|
|
|
17313
17313
|
}
|
|
17314
17314
|
|
|
17315
17315
|
// plugins/kxm/src/mcp-server.ts
|
|
17316
|
-
var VERSION = "0.7.
|
|
17316
|
+
var VERSION = "0.7.160";
|
|
17317
17317
|
var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
17318
17318
|
var inbox = /* @__PURE__ */ new Map();
|
|
17319
17319
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
@@ -30790,18 +30790,46 @@ function stripOneClosingFence(trimmed) {
|
|
|
30790
30790
|
if (lineBreak < 0) return "";
|
|
30791
30791
|
return trimmed.slice(0, lineBreak).trimEnd();
|
|
30792
30792
|
}
|
|
30793
|
-
|
|
30794
|
-
|
|
30795
|
-
|
|
30796
|
-
|
|
30797
|
-
|
|
30798
|
-
|
|
30793
|
+
var MAX_OUTCOME_CLOSERS = 32;
|
|
30794
|
+
var MAX_OUTCOME_OPENERS = 32;
|
|
30795
|
+
var MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
|
|
30796
|
+
var MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
|
|
30797
|
+
var MAX_PREFIX_CONTAINER_OPENERS = 16;
|
|
30798
|
+
function outcomePrefixIsAnchored(prefix) {
|
|
30799
|
+
let objectDepth = 0;
|
|
30800
|
+
let arrayDepth = 0;
|
|
30801
|
+
let inString = false;
|
|
30802
|
+
let escape2 = false;
|
|
30803
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
30804
|
+
const ch = prefix[i];
|
|
30805
|
+
if (inString) {
|
|
30806
|
+
if (escape2) {
|
|
30807
|
+
escape2 = false;
|
|
30808
|
+
continue;
|
|
30809
|
+
}
|
|
30810
|
+
if (ch === "\\") {
|
|
30811
|
+
escape2 = true;
|
|
30812
|
+
continue;
|
|
30813
|
+
}
|
|
30814
|
+
if (ch === '"') inString = false;
|
|
30815
|
+
continue;
|
|
30816
|
+
}
|
|
30817
|
+
if (ch === '"') {
|
|
30818
|
+
inString = true;
|
|
30819
|
+
continue;
|
|
30820
|
+
}
|
|
30821
|
+
if (ch === "{") objectDepth += 1;
|
|
30822
|
+
else if (ch === "}" && objectDepth > 0) objectDepth -= 1;
|
|
30823
|
+
else if (ch === "[") arrayDepth += 1;
|
|
30824
|
+
else if (ch === "]" && arrayDepth > 0) arrayDepth -= 1;
|
|
30825
|
+
}
|
|
30826
|
+
return objectDepth === 0 && arrayDepth === 0 && !inString && !escape2;
|
|
30827
|
+
}
|
|
30828
|
+
function containerLexIsTruncated(body, start) {
|
|
30829
|
+
const stack = [];
|
|
30799
30830
|
let inString = false;
|
|
30800
30831
|
let escape2 = false;
|
|
30801
|
-
let
|
|
30802
|
-
let objectStart = -1;
|
|
30803
|
-
let objectEnd = -1;
|
|
30804
|
-
for (let i = 0; i < body.length; i++) {
|
|
30832
|
+
for (let i = start; i < body.length; i++) {
|
|
30805
30833
|
const ch = body[i];
|
|
30806
30834
|
if (inString) {
|
|
30807
30835
|
if (escape2) {
|
|
@@ -30819,31 +30847,67 @@ function determineOutcome(text, allowedOutcomes) {
|
|
|
30819
30847
|
inString = true;
|
|
30820
30848
|
continue;
|
|
30821
30849
|
}
|
|
30822
|
-
if (ch === "{") {
|
|
30823
|
-
|
|
30824
|
-
depth++;
|
|
30850
|
+
if (ch === "{" || ch === "[") {
|
|
30851
|
+
stack.push(ch);
|
|
30825
30852
|
continue;
|
|
30826
30853
|
}
|
|
30827
|
-
if (ch === "}") {
|
|
30828
|
-
|
|
30829
|
-
|
|
30830
|
-
if (
|
|
30831
|
-
|
|
30832
|
-
objectEnd = i;
|
|
30833
|
-
}
|
|
30854
|
+
if (ch === "}" || ch === "]") {
|
|
30855
|
+
const open2 = ch === "}" ? "{" : "[";
|
|
30856
|
+
const top = stack[stack.length - 1];
|
|
30857
|
+
if (top !== open2) return false;
|
|
30858
|
+
stack.pop();
|
|
30834
30859
|
}
|
|
30835
30860
|
}
|
|
30836
|
-
|
|
30837
|
-
|
|
30838
|
-
|
|
30839
|
-
|
|
30840
|
-
|
|
30841
|
-
|
|
30861
|
+
return stack.length > 0 || inString || escape2;
|
|
30862
|
+
}
|
|
30863
|
+
function prefixHidesTruncatedContainer(body, openAt) {
|
|
30864
|
+
let seen = 0;
|
|
30865
|
+
for (let i = openAt - 1; i >= 0; i--) {
|
|
30866
|
+
const ch = body[i];
|
|
30867
|
+
if (ch !== "{" && ch !== "[") continue;
|
|
30868
|
+
if (seen >= MAX_PREFIX_CONTAINER_OPENERS) return true;
|
|
30869
|
+
seen++;
|
|
30870
|
+
if (containerLexIsTruncated(body, i)) return true;
|
|
30871
|
+
}
|
|
30872
|
+
return false;
|
|
30873
|
+
}
|
|
30874
|
+
function determineOutcome(text, allowedOutcomes) {
|
|
30875
|
+
const trimmed = text.trim();
|
|
30876
|
+
if (!trimmed) return "failed";
|
|
30877
|
+
const body = stripOneClosingFence(trimmed);
|
|
30878
|
+
if (!body) return "failed";
|
|
30879
|
+
let seenClosers = 0;
|
|
30880
|
+
let parseBytes = 0;
|
|
30881
|
+
for (let closeAt = body.length - 1; closeAt >= 0 && seenClosers < MAX_OUTCOME_CLOSERS; closeAt--) {
|
|
30882
|
+
if (body[closeAt] !== "}") continue;
|
|
30883
|
+
seenClosers++;
|
|
30884
|
+
const end = closeAt + 1;
|
|
30885
|
+
if (end !== body.length) continue;
|
|
30886
|
+
let seenOpeners = 0;
|
|
30887
|
+
for (let openAt = closeAt - 1; openAt >= 0 && seenOpeners < MAX_OUTCOME_OPENERS; openAt--) {
|
|
30888
|
+
if (body[openAt] !== "{") continue;
|
|
30889
|
+
seenOpeners++;
|
|
30890
|
+
const slice = body.slice(openAt, end);
|
|
30891
|
+
const sliceBytes = Buffer.byteLength(slice, "utf8");
|
|
30892
|
+
if (sliceBytes > MAX_OUTCOME_SLICE_BYTES) break;
|
|
30893
|
+
if (parseBytes + sliceBytes > MAX_OUTCOME_PARSE_BYTES) return "failed";
|
|
30894
|
+
parseBytes += sliceBytes;
|
|
30895
|
+
let result;
|
|
30896
|
+
try {
|
|
30897
|
+
result = JSON.parse(slice);
|
|
30898
|
+
} catch {
|
|
30899
|
+
continue;
|
|
30900
|
+
}
|
|
30901
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) continue;
|
|
30902
|
+
const outcome = result.outcome;
|
|
30903
|
+
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) continue;
|
|
30904
|
+
const prefix = body.slice(0, openAt).trimEnd();
|
|
30905
|
+
if (!outcomePrefixIsAnchored(prefix)) continue;
|
|
30906
|
+
if (prefixHidesTruncatedContainer(body, openAt)) continue;
|
|
30907
|
+
return outcome;
|
|
30908
|
+
}
|
|
30842
30909
|
}
|
|
30843
|
-
|
|
30844
|
-
const outcome = result.outcome;
|
|
30845
|
-
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
|
|
30846
|
-
return outcome;
|
|
30910
|
+
return "failed";
|
|
30847
30911
|
}
|
|
30848
30912
|
function createKxmOneShotProducer(options = {}) {
|
|
30849
30913
|
const running = /* @__PURE__ */ new Map();
|
|
@@ -31538,18 +31538,46 @@ function stripOneClosingFence(trimmed) {
|
|
|
31538
31538
|
if (lineBreak < 0) return "";
|
|
31539
31539
|
return trimmed.slice(0, lineBreak).trimEnd();
|
|
31540
31540
|
}
|
|
31541
|
-
|
|
31542
|
-
|
|
31543
|
-
|
|
31544
|
-
|
|
31545
|
-
|
|
31546
|
-
|
|
31541
|
+
var MAX_OUTCOME_CLOSERS = 32;
|
|
31542
|
+
var MAX_OUTCOME_OPENERS = 32;
|
|
31543
|
+
var MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
|
|
31544
|
+
var MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
|
|
31545
|
+
var MAX_PREFIX_CONTAINER_OPENERS = 16;
|
|
31546
|
+
function outcomePrefixIsAnchored(prefix) {
|
|
31547
|
+
let objectDepth = 0;
|
|
31548
|
+
let arrayDepth = 0;
|
|
31549
|
+
let inString = false;
|
|
31550
|
+
let escape2 = false;
|
|
31551
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
31552
|
+
const ch = prefix[i];
|
|
31553
|
+
if (inString) {
|
|
31554
|
+
if (escape2) {
|
|
31555
|
+
escape2 = false;
|
|
31556
|
+
continue;
|
|
31557
|
+
}
|
|
31558
|
+
if (ch === "\\") {
|
|
31559
|
+
escape2 = true;
|
|
31560
|
+
continue;
|
|
31561
|
+
}
|
|
31562
|
+
if (ch === '"') inString = false;
|
|
31563
|
+
continue;
|
|
31564
|
+
}
|
|
31565
|
+
if (ch === '"') {
|
|
31566
|
+
inString = true;
|
|
31567
|
+
continue;
|
|
31568
|
+
}
|
|
31569
|
+
if (ch === "{") objectDepth += 1;
|
|
31570
|
+
else if (ch === "}" && objectDepth > 0) objectDepth -= 1;
|
|
31571
|
+
else if (ch === "[") arrayDepth += 1;
|
|
31572
|
+
else if (ch === "]" && arrayDepth > 0) arrayDepth -= 1;
|
|
31573
|
+
}
|
|
31574
|
+
return objectDepth === 0 && arrayDepth === 0 && !inString && !escape2;
|
|
31575
|
+
}
|
|
31576
|
+
function containerLexIsTruncated(body, start) {
|
|
31577
|
+
const stack = [];
|
|
31547
31578
|
let inString = false;
|
|
31548
31579
|
let escape2 = false;
|
|
31549
|
-
let
|
|
31550
|
-
let objectStart = -1;
|
|
31551
|
-
let objectEnd = -1;
|
|
31552
|
-
for (let i = 0; i < body.length; i++) {
|
|
31580
|
+
for (let i = start; i < body.length; i++) {
|
|
31553
31581
|
const ch = body[i];
|
|
31554
31582
|
if (inString) {
|
|
31555
31583
|
if (escape2) {
|
|
@@ -31567,31 +31595,67 @@ function determineOutcome(text, allowedOutcomes) {
|
|
|
31567
31595
|
inString = true;
|
|
31568
31596
|
continue;
|
|
31569
31597
|
}
|
|
31570
|
-
if (ch === "{") {
|
|
31571
|
-
|
|
31572
|
-
depth++;
|
|
31598
|
+
if (ch === "{" || ch === "[") {
|
|
31599
|
+
stack.push(ch);
|
|
31573
31600
|
continue;
|
|
31574
31601
|
}
|
|
31575
|
-
if (ch === "}") {
|
|
31576
|
-
|
|
31577
|
-
|
|
31578
|
-
if (
|
|
31579
|
-
|
|
31580
|
-
objectEnd = i;
|
|
31581
|
-
}
|
|
31602
|
+
if (ch === "}" || ch === "]") {
|
|
31603
|
+
const open2 = ch === "}" ? "{" : "[";
|
|
31604
|
+
const top = stack[stack.length - 1];
|
|
31605
|
+
if (top !== open2) return false;
|
|
31606
|
+
stack.pop();
|
|
31582
31607
|
}
|
|
31583
31608
|
}
|
|
31584
|
-
|
|
31585
|
-
|
|
31586
|
-
|
|
31587
|
-
|
|
31588
|
-
|
|
31589
|
-
|
|
31609
|
+
return stack.length > 0 || inString || escape2;
|
|
31610
|
+
}
|
|
31611
|
+
function prefixHidesTruncatedContainer(body, openAt) {
|
|
31612
|
+
let seen = 0;
|
|
31613
|
+
for (let i = openAt - 1; i >= 0; i--) {
|
|
31614
|
+
const ch = body[i];
|
|
31615
|
+
if (ch !== "{" && ch !== "[") continue;
|
|
31616
|
+
if (seen >= MAX_PREFIX_CONTAINER_OPENERS) return true;
|
|
31617
|
+
seen++;
|
|
31618
|
+
if (containerLexIsTruncated(body, i)) return true;
|
|
31619
|
+
}
|
|
31620
|
+
return false;
|
|
31621
|
+
}
|
|
31622
|
+
function determineOutcome(text, allowedOutcomes) {
|
|
31623
|
+
const trimmed = text.trim();
|
|
31624
|
+
if (!trimmed) return "failed";
|
|
31625
|
+
const body = stripOneClosingFence(trimmed);
|
|
31626
|
+
if (!body) return "failed";
|
|
31627
|
+
let seenClosers = 0;
|
|
31628
|
+
let parseBytes = 0;
|
|
31629
|
+
for (let closeAt = body.length - 1; closeAt >= 0 && seenClosers < MAX_OUTCOME_CLOSERS; closeAt--) {
|
|
31630
|
+
if (body[closeAt] !== "}") continue;
|
|
31631
|
+
seenClosers++;
|
|
31632
|
+
const end = closeAt + 1;
|
|
31633
|
+
if (end !== body.length) continue;
|
|
31634
|
+
let seenOpeners = 0;
|
|
31635
|
+
for (let openAt = closeAt - 1; openAt >= 0 && seenOpeners < MAX_OUTCOME_OPENERS; openAt--) {
|
|
31636
|
+
if (body[openAt] !== "{") continue;
|
|
31637
|
+
seenOpeners++;
|
|
31638
|
+
const slice = body.slice(openAt, end);
|
|
31639
|
+
const sliceBytes = Buffer.byteLength(slice, "utf8");
|
|
31640
|
+
if (sliceBytes > MAX_OUTCOME_SLICE_BYTES) break;
|
|
31641
|
+
if (parseBytes + sliceBytes > MAX_OUTCOME_PARSE_BYTES) return "failed";
|
|
31642
|
+
parseBytes += sliceBytes;
|
|
31643
|
+
let result;
|
|
31644
|
+
try {
|
|
31645
|
+
result = JSON.parse(slice);
|
|
31646
|
+
} catch {
|
|
31647
|
+
continue;
|
|
31648
|
+
}
|
|
31649
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) continue;
|
|
31650
|
+
const outcome = result.outcome;
|
|
31651
|
+
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) continue;
|
|
31652
|
+
const prefix = body.slice(0, openAt).trimEnd();
|
|
31653
|
+
if (!outcomePrefixIsAnchored(prefix)) continue;
|
|
31654
|
+
if (prefixHidesTruncatedContainer(body, openAt)) continue;
|
|
31655
|
+
return outcome;
|
|
31656
|
+
}
|
|
31590
31657
|
}
|
|
31591
|
-
|
|
31592
|
-
const outcome = result.outcome;
|
|
31593
|
-
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
|
|
31594
|
-
return outcome;
|
|
31658
|
+
return "failed";
|
|
31595
31659
|
}
|
|
31596
31660
|
function createKxmOneShotProducer(options = {}) {
|
|
31597
31661
|
const running = /* @__PURE__ */ new Map();
|
package/plugins/kxm/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.160";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -65,32 +65,77 @@ function stripOneClosingFence(trimmed: string): string {
|
|
|
65
65
|
return trimmed.slice(0, lineBreak).trimEnd();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
68
|
+
// How many `}` characters, counting from the end of the reply, we will try
|
|
69
|
+
// as the end of an outcome object.
|
|
70
|
+
const MAX_OUTCOME_CLOSERS = 32;
|
|
71
|
+
|
|
72
|
+
// How many `{` openers before that closer we will consider, nearest last.
|
|
73
|
+
const MAX_OUTCOME_OPENERS = 32;
|
|
74
|
+
|
|
75
|
+
// A candidate whose UTF-8 size is over this is not parsed. Older openers
|
|
76
|
+
// for the same closer contain the nearer slice, so the opener walk stops
|
|
77
|
+
// there. The count is Buffer.byteLength, not JavaScript string length.
|
|
78
|
+
const MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
|
|
79
|
+
|
|
80
|
+
// Total UTF-8 bytes of candidate slices JSON.parse may see in one lookup.
|
|
81
|
+
// Crossing this budget stops the lookup. Same byte measure as the slice cap.
|
|
82
|
+
const MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
|
|
83
|
+
|
|
84
|
+
// How many `{` / `[` openers at the end of the prefix are re-lexed. A `"`
|
|
85
|
+
// is not an opener. Each re-lex walks the body from that opener to the end,
|
|
86
|
+
// so the lexer work is linear in the body per opener checked, per candidate.
|
|
87
|
+
// This cap only limits how many openers are re-lexed. It does not bound the
|
|
88
|
+
// lexer. The slice budgets bound JSON.parse bytes, not these scans. If the
|
|
89
|
+
// walk reaches the cap and another container opener is still earlier in the
|
|
90
|
+
// prefix, the prefix is ambiguous and the candidate is rejected.
|
|
91
|
+
const MAX_PREFIX_CONTAINER_OPENERS = 16;
|
|
92
|
+
|
|
93
|
+
// Naive prefix scan. Quote handling toggles on every unescaped `"`. Braces
|
|
94
|
+
// and brackets count only outside a string. Extra closers stay at depth 0
|
|
95
|
+
// because they are prose, not an unclosed container. The slice itself is
|
|
96
|
+
// not scanned: JSON.parse owns strings and escapes there. O(prefix).
|
|
97
|
+
function outcomePrefixIsAnchored(prefix: string): boolean {
|
|
98
|
+
let objectDepth = 0;
|
|
99
|
+
let arrayDepth = 0;
|
|
88
100
|
let inString = false;
|
|
89
101
|
let escape = false;
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
103
|
+
const ch = prefix[i];
|
|
104
|
+
if (inString) {
|
|
105
|
+
if (escape) {
|
|
106
|
+
escape = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "\\") {
|
|
110
|
+
escape = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === "\"") inString = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === "\"") {
|
|
117
|
+
inString = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (ch === "{") objectDepth += 1;
|
|
121
|
+
else if (ch === "}" && objectDepth > 0) objectDepth -= 1;
|
|
122
|
+
else if (ch === "[") arrayDepth += 1;
|
|
123
|
+
else if (ch === "]" && arrayDepth > 0) arrayDepth -= 1;
|
|
124
|
+
}
|
|
125
|
+
return objectDepth === 0 && arrayDepth === 0 && !inString && !escape;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Same string, escape, and depth machine as the prefix scan, started at a
|
|
129
|
+
// `{` or `[`. A closer that does not match an opener this scan pushed is
|
|
130
|
+
// invalid. An invalid scan is not truncation. Truncation is a valid scan
|
|
131
|
+
// that reaches the end of the body still inside a container or a string.
|
|
132
|
+
// One call walks from `start` through the rest of the body. The walk is
|
|
133
|
+
// linear in that span. No byte budget stops it.
|
|
134
|
+
function containerLexIsTruncated(body: string, start: number): boolean {
|
|
135
|
+
const stack: string[] = [];
|
|
136
|
+
let inString = false;
|
|
137
|
+
let escape = false;
|
|
138
|
+
for (let i = start; i < body.length; i++) {
|
|
94
139
|
const ch = body[i];
|
|
95
140
|
if (inString) {
|
|
96
141
|
if (escape) {
|
|
@@ -101,38 +146,103 @@ function determineOutcome(text: string, allowedOutcomes: readonly string[]): str
|
|
|
101
146
|
escape = true;
|
|
102
147
|
continue;
|
|
103
148
|
}
|
|
104
|
-
if (ch ===
|
|
149
|
+
if (ch === "\"") inString = false;
|
|
105
150
|
continue;
|
|
106
151
|
}
|
|
107
|
-
if (ch ===
|
|
152
|
+
if (ch === "\"") {
|
|
108
153
|
inString = true;
|
|
109
154
|
continue;
|
|
110
155
|
}
|
|
111
|
-
if (ch === "{") {
|
|
112
|
-
|
|
113
|
-
depth++;
|
|
156
|
+
if (ch === "{" || ch === "[") {
|
|
157
|
+
stack.push(ch);
|
|
114
158
|
continue;
|
|
115
159
|
}
|
|
116
|
-
if (ch === "}") {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
objectEnd = i;
|
|
122
|
-
}
|
|
160
|
+
if (ch === "}" || ch === "]") {
|
|
161
|
+
const open = ch === "}" ? "{" : "[";
|
|
162
|
+
const top = stack[stack.length - 1];
|
|
163
|
+
if (top !== open) return false;
|
|
164
|
+
stack.pop();
|
|
123
165
|
}
|
|
124
166
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
return stack.length > 0 || inString || escape;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// True when one of the last container openers in the prefix lexes as a
|
|
171
|
+
// value that is still open at the end of the body, or when more container
|
|
172
|
+
// openers sit further back than the re-lex cap. Those further openers are
|
|
173
|
+
// not scanned. The prefix is ambiguous, so the candidate fails closed.
|
|
174
|
+
// Quote positions are left to the naive machine.
|
|
175
|
+
function prefixHidesTruncatedContainer(body: string, openAt: number): boolean {
|
|
176
|
+
let seen = 0;
|
|
177
|
+
for (let i = openAt - 1; i >= 0; i--) {
|
|
178
|
+
const ch = body[i];
|
|
179
|
+
if (ch !== "{" && ch !== "[") continue;
|
|
180
|
+
if (seen >= MAX_PREFIX_CONTAINER_OPENERS) return true;
|
|
181
|
+
seen++;
|
|
182
|
+
if (containerLexIsTruncated(body, i)) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The outcome object is a JSON object that ends the trimmed reply. One
|
|
188
|
+
// closing code fence may follow it; the fence is removed before the walk.
|
|
189
|
+
// Candidates are slices from a `{` to a recent `}`, nearest last. Only the
|
|
190
|
+
// last 32 closers are visited, and only a closer on the reply's last
|
|
191
|
+
// character can end the reply. For that closer the last 32 openers are
|
|
192
|
+
// tried. A slice whose UTF-8 size is over 256 KiB is skipped. Parsed slices
|
|
193
|
+
// share a 1 MiB UTF-8 budget, and crossing it stops the lookup. Both limits
|
|
194
|
+
// use Buffer.byteLength. JSON.parse is the only
|
|
195
|
+
// string and escape authority inside the slice. A slice that parses to a
|
|
196
|
+
// plain object whose outcome is a string in allowedOutcomes is a candidate.
|
|
197
|
+
// The prefix before that opener, with trailing whitespace removed, must end
|
|
198
|
+
// at depth 0 for both braces and arrays, outside a string, with no dangling
|
|
199
|
+
// escape. Any other end state is truncated or ambiguous, so the candidate
|
|
200
|
+
// is rejected and the next one is tried. The naive machine plus the
|
|
201
|
+
// container-lexer guard reject truncation. A prefix with more container
|
|
202
|
+
// openers than the re-lex cap is ambiguous and fails closed. Prose with an
|
|
203
|
+
// odd quote count fails closed. An accepted candidate ends the lookup.
|
|
204
|
+
// Nothing left is failed: prose after the object, no object, a disallowed
|
|
205
|
+
// outcome, an inner object at the end of a truncated outer object or array,
|
|
206
|
+
// an object inside an unclosed string, an ambiguous prefix past the re-lex
|
|
207
|
+
// cap, or a budget exhausted before a candidate.
|
|
208
|
+
function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
|
|
209
|
+
const trimmed = text.trim();
|
|
210
|
+
if (!trimmed) return "failed";
|
|
211
|
+
const body = stripOneClosingFence(trimmed);
|
|
212
|
+
if (!body) return "failed";
|
|
213
|
+
let seenClosers = 0;
|
|
214
|
+
let parseBytes = 0;
|
|
215
|
+
for (let closeAt = body.length - 1; closeAt >= 0 && seenClosers < MAX_OUTCOME_CLOSERS; closeAt--) {
|
|
216
|
+
if (body[closeAt] !== "}") continue;
|
|
217
|
+
seenClosers++;
|
|
218
|
+
const end = closeAt + 1;
|
|
219
|
+
// A candidate has to end the reply. One closer can sit at that position.
|
|
220
|
+
if (end !== body.length) continue;
|
|
221
|
+
let seenOpeners = 0;
|
|
222
|
+
for (let openAt = closeAt - 1; openAt >= 0 && seenOpeners < MAX_OUTCOME_OPENERS; openAt--) {
|
|
223
|
+
if (body[openAt] !== "{") continue;
|
|
224
|
+
seenOpeners++;
|
|
225
|
+
const slice = body.slice(openAt, end);
|
|
226
|
+
const sliceBytes = Buffer.byteLength(slice, "utf8");
|
|
227
|
+
if (sliceBytes > MAX_OUTCOME_SLICE_BYTES) break;
|
|
228
|
+
if (parseBytes + sliceBytes > MAX_OUTCOME_PARSE_BYTES) return "failed";
|
|
229
|
+
parseBytes += sliceBytes;
|
|
230
|
+
let result: unknown;
|
|
231
|
+
try {
|
|
232
|
+
result = JSON.parse(slice);
|
|
233
|
+
} catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) continue;
|
|
237
|
+
const outcome = (result as Record<string, unknown>).outcome;
|
|
238
|
+
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) continue;
|
|
239
|
+
const prefix = body.slice(0, openAt).trimEnd();
|
|
240
|
+
if (!outcomePrefixIsAnchored(prefix)) continue;
|
|
241
|
+
if (prefixHidesTruncatedContainer(body, openAt)) continue;
|
|
242
|
+
return outcome;
|
|
243
|
+
}
|
|
131
244
|
}
|
|
132
|
-
|
|
133
|
-
const outcome = (result as Record<string, unknown>).outcome;
|
|
134
|
-
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
|
|
135
|
-
return outcome;
|
|
245
|
+
return "failed";
|
|
136
246
|
}
|
|
137
247
|
|
|
138
248
|
export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}): KxmOneShotProducer {
|