@kontextmind/kxm 0.7.152 → 0.7.153
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 +63 -47
- package/docs/contracts/lifecycles.md +12 -4
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +31 -45
- package/plugins/kxm/dist/runtime.js +31 -45
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/oneshot-producer.ts +49 -60
package/CHANGELOG.md
CHANGED
|
@@ -6,20 +6,6 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
-
- **An opted-in role continues a failed attempt on the next admitted route.**
|
|
10
|
-
`policy.fallback.onError` lists `rate_limit`, `transport`,
|
|
11
|
-
`provider_unavailable`, and `context_overflow`. An empty or absent list is
|
|
12
|
-
unchanged: the attempt fails on the first route. The walk is bounded by
|
|
13
|
-
`maxSwitches` (default 1), skips a critic-vendor collision when the role
|
|
14
|
-
requires vendor independence, and records `routing.route_switched` plus a
|
|
15
|
-
`route_switch` log line. A bounded redacted transcript is appended to the
|
|
16
|
-
next prompt. `revert: never` keeps the successful route for later steps in
|
|
17
|
-
the same run. `kxm routing report` and `kxm improve report` show the switch.
|
|
18
|
-
Cancellation, policy refusal, authentication, an unhosted model, a gate
|
|
19
|
-
failure, tool policy, and admission errors do not walk. See
|
|
20
|
-
[role fallback](docs/reference/config-reference.md#kxmrolesroleyaml-kxmrolev2)
|
|
21
|
-
and [mid-attempt fallback](docs/reference/harness-routing.md#mid-attempt-fallback).
|
|
22
|
-
|
|
23
9
|
- **A live agent step uses a configurable one-shot timeout, and a cancelling run recovers when its child has already exited.**
|
|
24
10
|
The bound is the step `timeoutMs`, or the project `limits.agentStepTimeoutMs`
|
|
25
11
|
when the step omits it (minimum 60 seconds, default one hour). A wider step
|
|
@@ -154,38 +140,21 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
154
140
|
|
|
155
141
|
- **Playwright uses Obscura by default.** `resolveBrowserCdpEndpoint()` returns `OBSCURA_CDP_URL` or `http://127.0.0.1:${OBSCURA_PORT:-9222}`. `KXM_BROWSER=steel` still returns the Steel session CDP URL. `node scripts/obscura.mjs` downloads pinned Obscura v0.2.3 and serves it with `--allow-private-network`. `npm run e2e` runs the Playwright smoke test in `test/e2e/` over CDP and does not run `playwright install`. Steel remains the path for human takeover, MFA, and the live session viewer. See [ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md) and [How do I connect Playwright to Obscura?](docs/kb/how-to-connect-playwright-to-obscura.md).
|
|
156
142
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
143
|
+
- **An opted-in role continues a failed attempt on the next admitted route.**
|
|
144
|
+
`policy.fallback.onError` lists `rate_limit`, `transport`,
|
|
145
|
+
`provider_unavailable`, and `context_overflow`. An empty or absent list is
|
|
146
|
+
unchanged: the attempt fails on the first route. The walk is bounded by
|
|
147
|
+
`maxSwitches` (default 1), skips a critic-vendor collision when the role
|
|
148
|
+
requires vendor independence, and records `routing.route_switched` plus a
|
|
149
|
+
`route_switch` log line. A bounded redacted transcript is appended to the
|
|
150
|
+
next prompt. `revert: never` keeps the successful route for later steps in
|
|
151
|
+
the same run. `kxm routing report` and `kxm improve report` show the switch.
|
|
152
|
+
Cancellation, policy refusal, authentication, an unhosted model, a gate
|
|
153
|
+
failure, tool policy, and admission errors do not walk. See
|
|
154
|
+
[role fallback](docs/reference/config-reference.md#kxmrolesroleyaml-kxmrolev2)
|
|
155
|
+
and [mid-attempt fallback](docs/reference/harness-routing.md#mid-attempt-fallback).
|
|
167
156
|
|
|
168
|
-
|
|
169
|
-
Steel (`steel.kontextmind.com`, alias `steel.theneuro.me`) is reached only
|
|
170
|
-
through Caddy on `kxmd-proxy` (VM 230) and Authentik forward auth. Direct
|
|
171
|
-
LAN, tailnet, and host-forward access is blocked. Sessions return
|
|
172
|
-
`websocketUrl` `wss://steel.kontextmind.com/` (previously
|
|
173
|
-
`ws://steel-browser/`). The CDP path is `/v1/devtools` with an
|
|
174
|
-
`Authorization` header. Allowed groups are `steel-users`, `kxmd-users`,
|
|
175
|
-
`kxmd-admins`, and `kxmd-owners`. `STEEL_API_URL` defaults to
|
|
176
|
-
`https://steel.kontextmind.com`. `STEEL_API_KEY` is deprecated and is not
|
|
177
|
-
enforced by Steel or Caddy. Migrate to `STEEL_AUTH_HEADER`, then
|
|
178
|
-
`STEEL_AUTH_BASIC`, then `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`; those
|
|
179
|
-
override the key. The `svc-steel` credential is 1Password vault
|
|
180
|
-
`kontextmind`, item `Steel (svc-steel)`, field `basic_auth`, read with
|
|
181
|
-
`op read` and never written to disk. Steel requires `kxm` 0.7.135 or
|
|
182
|
-
newer. Playwright stays on Obscura ([ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md)).
|
|
183
|
-
The Proxmox boot order and VM names are in
|
|
184
|
-
[Deploy KXM](docs/operations/deploy.md#boot-the-kxmd-proxmox-host).
|
|
185
|
-
Machine accounts follow
|
|
186
|
-
[ADR-0006](docs/adr/ADR-0006-machine-account-names.md). The DOKS deployment
|
|
187
|
-
record is superseded by
|
|
188
|
-
[ADR-0007](docs/adr/ADR-0007-steel-caddy-authentik.md).
|
|
157
|
+
### Changed
|
|
189
158
|
|
|
190
159
|
- **Workforce ids use one convention, and old ids still resolve.**
|
|
191
160
|
Role ids stay `planner`, `writer`, `reviewer-arch`, and `reviewer-cli`.
|
|
@@ -511,6 +480,49 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
511
480
|
persisting a projection write, so a read cannot mutate run state or surface a
|
|
512
481
|
false `run_projection_divergent`.
|
|
513
482
|
|
|
483
|
+
- **Steel clients authenticate to Authentik with `Authorization: Basic`.**
|
|
484
|
+
`STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
|
|
485
|
+
header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
|
|
486
|
+
`KXM_BROWSER=steel` passes those headers through `connectBrowserOverCdp()`
|
|
487
|
+
into `chromium.connectOverCDP`. Obscura stays the default and sends no Steel
|
|
488
|
+
headers. `STEEL_AUTH_HEADER` overrides the value. The CDP URL omits the credential when
|
|
489
|
+
those variables are set. A 302 to the identity provider fails closed and does
|
|
490
|
+
not follow the login redirect. `STEEL_API_KEY` still sends the legacy
|
|
491
|
+
`x-steel-api-key` header and `apiKey` query parameter for the temporary proxy
|
|
492
|
+
shim, and warns once. See
|
|
493
|
+
[Browser automation](docs/guides/browser-automation.md).
|
|
494
|
+
|
|
495
|
+
- **Pull-request CI runs the unit suite on Linux Node 24, and `CI / required` is the aggregate check.**
|
|
496
|
+
Docs and plan markdown skip the code jobs. `engine.test.ts` is split by
|
|
497
|
+
test name. `permission.test.ts` and `runtime.test.ts` run one file at a
|
|
498
|
+
time, and every other unit file runs in one light lane. Pushes to `main`
|
|
499
|
+
still run `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24.
|
|
500
|
+
A pull request that touches path, process, shell, spawn, package, lockfile,
|
|
501
|
+
or workflow files also runs the unit lanes on Windows. Playwright stays on
|
|
502
|
+
Obscura. See [CI and release](docs/contributing/ci-and-release.md).
|
|
503
|
+
|
|
504
|
+
- **Docs match the 2026-09-27 Steel and machine-account infrastructure.**
|
|
505
|
+
Steel (`steel.kontextmind.com`, alias `steel.theneuro.me`) is reached only
|
|
506
|
+
through Caddy on `kxmd-proxy` (VM 230) and Authentik forward auth. Direct
|
|
507
|
+
LAN, tailnet, and host-forward access is blocked. Sessions return
|
|
508
|
+
`websocketUrl` `wss://steel.kontextmind.com/` (previously
|
|
509
|
+
`ws://steel-browser/`). The CDP path is `/v1/devtools` with an
|
|
510
|
+
`Authorization` header. Allowed groups are `steel-users`, `kxmd-users`,
|
|
511
|
+
`kxmd-admins`, and `kxmd-owners`. `STEEL_API_URL` defaults to
|
|
512
|
+
`https://steel.kontextmind.com`. `STEEL_API_KEY` is deprecated and is not
|
|
513
|
+
enforced by Steel or Caddy. Migrate to `STEEL_AUTH_HEADER`, then
|
|
514
|
+
`STEEL_AUTH_BASIC`, then `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`; those
|
|
515
|
+
override the key. The `svc-steel` credential is 1Password vault
|
|
516
|
+
`kontextmind`, item `Steel (svc-steel)`, field `basic_auth`, read with
|
|
517
|
+
`op read` and never written to disk. Steel requires `kxm` 0.7.135 or
|
|
518
|
+
newer. Playwright stays on Obscura ([ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md)).
|
|
519
|
+
The Proxmox boot order and VM names are in
|
|
520
|
+
[Deploy KXM](docs/operations/deploy.md#boot-the-kxmd-proxmox-host).
|
|
521
|
+
Machine accounts follow
|
|
522
|
+
[ADR-0006](docs/adr/ADR-0006-machine-account-names.md). The DOKS deployment
|
|
523
|
+
record is superseded by
|
|
524
|
+
[ADR-0007](docs/adr/ADR-0007-steel-caddy-authentik.md).
|
|
525
|
+
|
|
514
526
|
- **Steel clients authenticate to Authentik with `Authorization: Basic`.**
|
|
515
527
|
`STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
|
|
516
528
|
header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
|
|
@@ -542,9 +554,9 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
542
554
|
|
|
543
555
|
### Fixed
|
|
544
556
|
|
|
545
|
-
- **
|
|
557
|
+
- **A one-shot outcome is the JSON object that ends the reply.** One forward scan tracks strings, escapes, and brace depth. The newest top-level object, opened at depth 0, must close at the end of the trimmed text (one closing code fence and trailing whitespace allowed). `JSON.parse` runs once on that slice. The `outcome` must be a string in the step's declared values. Braces inside a string do not count as structure, so a summary may contain any number of `{` characters. A bare object, a fenced object, and prose followed by an object that ends the reply settle when the outcome is allowed. Prose after the object, a reply with no object, a disallowed outcome, a stray `"` in the prose, and an inner object at the end of a truncated outer object settle `failed`.
|
|
546
558
|
|
|
547
|
-
- **A committed checkout counts as authored work
|
|
559
|
+
- **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`.
|
|
548
560
|
|
|
549
561
|
- **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.
|
|
550
562
|
|
|
@@ -860,6 +872,10 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
860
872
|
|
|
861
873
|
- **`kxm vision` is owned by the browser-verify skill.** `plugins/kxm/skill-suite.json` lists `vision` on `kxm-browser-verify`, the skill that already teaches `kxm vision assert`.
|
|
862
874
|
|
|
875
|
+
- **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.
|
|
876
|
+
|
|
877
|
+
- **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`.
|
|
878
|
+
|
|
863
879
|
## 0.7.0 - 2026-09-11
|
|
864
880
|
|
|
865
881
|
### Added
|
|
@@ -105,10 +105,18 @@ created → accepted → dispatched → executing → result_recorded → termin
|
|
|
105
105
|
| `blocked_uncertain` | A dependent effect cannot be reconciled safely |
|
|
106
106
|
| `terminal` | The logical assignment outcome is final and immutable: passed, failed, or cancelled |
|
|
107
107
|
|
|
108
|
-
A producer reply becomes a terminal outcome **only** through a declared result
|
|
109
|
-
reply
|
|
110
|
-
|
|
111
|
-
the
|
|
108
|
+
A producer reply becomes a terminal outcome **only** through a declared result. A one-shot
|
|
109
|
+
reply declares that result when one forward scan finds a JSON object that closes at the
|
|
110
|
+
end of the trimmed text. The scan tracks strings, escapes, and brace depth together. A
|
|
111
|
+
`{` outside a string at depth 0 opens a top-level object, and the newest object that
|
|
112
|
+
returns to depth 0 is the candidate. One closing code fence may follow the object, with
|
|
113
|
+
whitespace. The object may be the whole reply, a fenced reply, or the end of surrounding
|
|
114
|
+
prose, on one line or several. `JSON.parse` runs once on that slice. The value must be a
|
|
115
|
+
plain object whose `outcome` string is one of the step's declared values. Braces inside
|
|
116
|
+
strings do not change depth. A stray `"` leaves the scan inside a string, so a following
|
|
117
|
+
object is not top-level and settles `failed`. Prose after the object, a reply with no
|
|
118
|
+
such object, a disallowed outcome, and an inner object at the end of a truncated outer
|
|
119
|
+
object settle `failed`.
|
|
112
120
|
|
|
113
121
|
Naming an outcome *word* anywhere in a reply is not a result. `"the gate did not pass, so I
|
|
114
122
|
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.153",
|
|
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",
|
|
@@ -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.153";
|
|
17317
17317
|
var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
17318
17318
|
var inbox = /* @__PURE__ */ new Map();
|
|
17319
17319
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
@@ -30773,15 +30773,29 @@ function recoverKxmRun(context, runId, request) {
|
|
|
30773
30773
|
}
|
|
30774
30774
|
|
|
30775
30775
|
// plugins/kxm/src/oneshot-producer.ts
|
|
30776
|
-
function
|
|
30776
|
+
function isClosingFence(line) {
|
|
30777
|
+
return /^(`{3,}|~{3,})\s*$/.test(line.trim());
|
|
30778
|
+
}
|
|
30779
|
+
function stripOneClosingFence(trimmed) {
|
|
30780
|
+
const lineBreak = Math.max(trimmed.lastIndexOf("\n"), trimmed.lastIndexOf("\r"));
|
|
30781
|
+
const lastLine = lineBreak < 0 ? trimmed : trimmed.slice(lineBreak + 1);
|
|
30782
|
+
if (!isClosingFence(lastLine)) return trimmed;
|
|
30783
|
+
if (lineBreak < 0) return "";
|
|
30784
|
+
return trimmed.slice(0, lineBreak).trimEnd();
|
|
30785
|
+
}
|
|
30786
|
+
function determineOutcome(text, allowedOutcomes) {
|
|
30787
|
+
const trimmed = text.trim();
|
|
30788
|
+
if (!trimmed) return "failed";
|
|
30789
|
+
const body = stripOneClosingFence(trimmed);
|
|
30790
|
+
if (!body) return "failed";
|
|
30777
30791
|
let depth = 0;
|
|
30778
30792
|
let inString = false;
|
|
30779
30793
|
let escape2 = false;
|
|
30780
|
-
let
|
|
30781
|
-
let
|
|
30782
|
-
let
|
|
30783
|
-
for (let i = 0; i <
|
|
30784
|
-
const ch =
|
|
30794
|
+
let currentStart = -1;
|
|
30795
|
+
let objectStart = -1;
|
|
30796
|
+
let objectEnd = -1;
|
|
30797
|
+
for (let i = 0; i < body.length; i++) {
|
|
30798
|
+
const ch = body[i];
|
|
30785
30799
|
if (inString) {
|
|
30786
30800
|
if (escape2) {
|
|
30787
30801
|
escape2 = false;
|
|
@@ -30799,7 +30813,7 @@ function lastBalancedJsonObject(text) {
|
|
|
30799
30813
|
continue;
|
|
30800
30814
|
}
|
|
30801
30815
|
if (ch === "{") {
|
|
30802
|
-
if (depth === 0)
|
|
30816
|
+
if (depth === 0) currentStart = i;
|
|
30803
30817
|
depth++;
|
|
30804
30818
|
continue;
|
|
30805
30819
|
}
|
|
@@ -30807,50 +30821,22 @@ function lastBalancedJsonObject(text) {
|
|
|
30807
30821
|
if (depth === 0) continue;
|
|
30808
30822
|
depth--;
|
|
30809
30823
|
if (depth === 0) {
|
|
30810
|
-
|
|
30811
|
-
|
|
30824
|
+
objectStart = currentStart;
|
|
30825
|
+
objectEnd = i;
|
|
30812
30826
|
}
|
|
30813
30827
|
}
|
|
30814
30828
|
}
|
|
30815
|
-
if (
|
|
30816
|
-
|
|
30817
|
-
}
|
|
30818
|
-
function standaloneObjectText(text) {
|
|
30819
|
-
const trimmed = text.trim();
|
|
30820
|
-
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return void 0;
|
|
30821
|
-
const slice = lastBalancedJsonObject(trimmed);
|
|
30822
|
-
return slice === trimmed ? slice : void 0;
|
|
30823
|
-
}
|
|
30824
|
-
function outcomeOfObjectText(text) {
|
|
30825
|
-
const slice = standaloneObjectText(text);
|
|
30826
|
-
if (slice === void 0) return void 0;
|
|
30829
|
+
if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
|
|
30830
|
+
let result;
|
|
30827
30831
|
try {
|
|
30828
|
-
|
|
30829
|
-
if (!result || typeof result !== "object" || Array.isArray(result)) return void 0;
|
|
30830
|
-
const outcome = result.outcome;
|
|
30831
|
-
return typeof outcome === "string" ? outcome : void 0;
|
|
30832
|
+
result = JSON.parse(body.slice(objectStart, objectEnd + 1));
|
|
30832
30833
|
} catch {
|
|
30833
|
-
return
|
|
30834
|
+
return "failed";
|
|
30834
30835
|
}
|
|
30835
|
-
|
|
30836
|
-
|
|
30837
|
-
|
|
30838
|
-
|
|
30839
|
-
function determineOutcome(text, allowedOutcomes) {
|
|
30840
|
-
const trimmed = text.trim();
|
|
30841
|
-
if (!trimmed) return "failed";
|
|
30842
|
-
const whole = outcomeOfObjectText(trimmed);
|
|
30843
|
-
if (whole !== void 0) return allowedOutcomes.includes(whole) ? whole : "failed";
|
|
30844
|
-
const lines = trimmed.split(/\r?\n/);
|
|
30845
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
30846
|
-
if (lines.length > 0 && isClosingFence(lines[lines.length - 1])) {
|
|
30847
|
-
lines.pop();
|
|
30848
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
30849
|
-
}
|
|
30850
|
-
if (lines.length === 0) return "failed";
|
|
30851
|
-
const declared = outcomeOfObjectText(lines[lines.length - 1]);
|
|
30852
|
-
if (declared === void 0 || !allowedOutcomes.includes(declared)) return "failed";
|
|
30853
|
-
return declared;
|
|
30836
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
|
|
30837
|
+
const outcome = result.outcome;
|
|
30838
|
+
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
|
|
30839
|
+
return outcome;
|
|
30854
30840
|
}
|
|
30855
30841
|
function createKxmOneShotProducer(options = {}) {
|
|
30856
30842
|
const running = /* @__PURE__ */ new Map();
|
|
@@ -31521,15 +31521,29 @@ function recoverKxmRun(context, runId, request) {
|
|
|
31521
31521
|
}
|
|
31522
31522
|
|
|
31523
31523
|
// plugins/kxm/src/oneshot-producer.ts
|
|
31524
|
-
function
|
|
31524
|
+
function isClosingFence(line) {
|
|
31525
|
+
return /^(`{3,}|~{3,})\s*$/.test(line.trim());
|
|
31526
|
+
}
|
|
31527
|
+
function stripOneClosingFence(trimmed) {
|
|
31528
|
+
const lineBreak = Math.max(trimmed.lastIndexOf("\n"), trimmed.lastIndexOf("\r"));
|
|
31529
|
+
const lastLine = lineBreak < 0 ? trimmed : trimmed.slice(lineBreak + 1);
|
|
31530
|
+
if (!isClosingFence(lastLine)) return trimmed;
|
|
31531
|
+
if (lineBreak < 0) return "";
|
|
31532
|
+
return trimmed.slice(0, lineBreak).trimEnd();
|
|
31533
|
+
}
|
|
31534
|
+
function determineOutcome(text, allowedOutcomes) {
|
|
31535
|
+
const trimmed = text.trim();
|
|
31536
|
+
if (!trimmed) return "failed";
|
|
31537
|
+
const body = stripOneClosingFence(trimmed);
|
|
31538
|
+
if (!body) return "failed";
|
|
31525
31539
|
let depth = 0;
|
|
31526
31540
|
let inString = false;
|
|
31527
31541
|
let escape2 = false;
|
|
31528
|
-
let
|
|
31529
|
-
let
|
|
31530
|
-
let
|
|
31531
|
-
for (let i = 0; i <
|
|
31532
|
-
const ch =
|
|
31542
|
+
let currentStart = -1;
|
|
31543
|
+
let objectStart = -1;
|
|
31544
|
+
let objectEnd = -1;
|
|
31545
|
+
for (let i = 0; i < body.length; i++) {
|
|
31546
|
+
const ch = body[i];
|
|
31533
31547
|
if (inString) {
|
|
31534
31548
|
if (escape2) {
|
|
31535
31549
|
escape2 = false;
|
|
@@ -31547,7 +31561,7 @@ function lastBalancedJsonObject(text) {
|
|
|
31547
31561
|
continue;
|
|
31548
31562
|
}
|
|
31549
31563
|
if (ch === "{") {
|
|
31550
|
-
if (depth === 0)
|
|
31564
|
+
if (depth === 0) currentStart = i;
|
|
31551
31565
|
depth++;
|
|
31552
31566
|
continue;
|
|
31553
31567
|
}
|
|
@@ -31555,50 +31569,22 @@ function lastBalancedJsonObject(text) {
|
|
|
31555
31569
|
if (depth === 0) continue;
|
|
31556
31570
|
depth--;
|
|
31557
31571
|
if (depth === 0) {
|
|
31558
|
-
|
|
31559
|
-
|
|
31572
|
+
objectStart = currentStart;
|
|
31573
|
+
objectEnd = i;
|
|
31560
31574
|
}
|
|
31561
31575
|
}
|
|
31562
31576
|
}
|
|
31563
|
-
if (
|
|
31564
|
-
|
|
31565
|
-
}
|
|
31566
|
-
function standaloneObjectText(text) {
|
|
31567
|
-
const trimmed = text.trim();
|
|
31568
|
-
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return void 0;
|
|
31569
|
-
const slice = lastBalancedJsonObject(trimmed);
|
|
31570
|
-
return slice === trimmed ? slice : void 0;
|
|
31571
|
-
}
|
|
31572
|
-
function outcomeOfObjectText(text) {
|
|
31573
|
-
const slice = standaloneObjectText(text);
|
|
31574
|
-
if (slice === void 0) return void 0;
|
|
31577
|
+
if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
|
|
31578
|
+
let result;
|
|
31575
31579
|
try {
|
|
31576
|
-
|
|
31577
|
-
if (!result || typeof result !== "object" || Array.isArray(result)) return void 0;
|
|
31578
|
-
const outcome = result.outcome;
|
|
31579
|
-
return typeof outcome === "string" ? outcome : void 0;
|
|
31580
|
+
result = JSON.parse(body.slice(objectStart, objectEnd + 1));
|
|
31580
31581
|
} catch {
|
|
31581
|
-
return
|
|
31582
|
+
return "failed";
|
|
31582
31583
|
}
|
|
31583
|
-
|
|
31584
|
-
|
|
31585
|
-
|
|
31586
|
-
|
|
31587
|
-
function determineOutcome(text, allowedOutcomes) {
|
|
31588
|
-
const trimmed = text.trim();
|
|
31589
|
-
if (!trimmed) return "failed";
|
|
31590
|
-
const whole = outcomeOfObjectText(trimmed);
|
|
31591
|
-
if (whole !== void 0) return allowedOutcomes.includes(whole) ? whole : "failed";
|
|
31592
|
-
const lines = trimmed.split(/\r?\n/);
|
|
31593
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
31594
|
-
if (lines.length > 0 && isClosingFence(lines[lines.length - 1])) {
|
|
31595
|
-
lines.pop();
|
|
31596
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
31597
|
-
}
|
|
31598
|
-
if (lines.length === 0) return "failed";
|
|
31599
|
-
const declared = outcomeOfObjectText(lines[lines.length - 1]);
|
|
31600
|
-
if (declared === void 0 || !allowedOutcomes.includes(declared)) return "failed";
|
|
31601
|
-
return declared;
|
|
31584
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
|
|
31585
|
+
const outcome = result.outcome;
|
|
31586
|
+
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
|
|
31587
|
+
return outcome;
|
|
31602
31588
|
}
|
|
31603
31589
|
function createKxmOneShotProducer(options = {}) {
|
|
31604
31590
|
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.153";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -51,21 +51,47 @@ export interface KxmOneShotProducer extends KxmProducer {
|
|
|
51
51
|
close(): Promise<void>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
function
|
|
54
|
+
function isClosingFence(line: string): boolean {
|
|
55
|
+
return /^(`{3,}|~{3,})\s*$/.test(line.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Drop one closing code fence when it is the last line. The caller has already
|
|
59
|
+
// trimmed the reply. Any other tail stays, so prose after the object still fails.
|
|
60
|
+
function stripOneClosingFence(trimmed: string): string {
|
|
61
|
+
const lineBreak = Math.max(trimmed.lastIndexOf("\n"), trimmed.lastIndexOf("\r"));
|
|
62
|
+
const lastLine = lineBreak < 0 ? trimmed : trimmed.slice(lineBreak + 1);
|
|
63
|
+
if (!isClosingFence(lastLine)) return trimmed;
|
|
64
|
+
if (lineBreak < 0) return "";
|
|
65
|
+
return trimmed.slice(0, lineBreak).trimEnd();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The outcome object is the newest complete top-level JSON object in the
|
|
69
|
+
// trimmed reply, and that object must close on the reply's last character.
|
|
70
|
+
// One closing code fence may follow it; the fence is removed before the scan.
|
|
71
|
+
// One forward pass tracks string state and brace depth together. A `"` toggles
|
|
72
|
+
// a string, and `\` escapes only inside a string. A `{` outside a string at
|
|
73
|
+
// depth 0 opens a top-level object. When depth returns to 0, that object is
|
|
74
|
+
// the newest complete top-level object. Braces inside strings do not change
|
|
75
|
+
// depth, so the same scan shows that the object started at depth 0 and that
|
|
76
|
+
// quotes in the prefix are closed. After the scan, if that object closes at
|
|
77
|
+
// the end of the body, JSON.parse runs once on the slice. The value must be
|
|
78
|
+
// a plain object whose outcome is a string in allowedOutcomes. Anything else
|
|
79
|
+
// is failed: prose after the object, no object, a disallowed outcome, an
|
|
80
|
+
// inner object at the end of a truncated outer object, a stray quote that
|
|
81
|
+
// leaves a string open, or a prefix whose braces never return to depth 0.
|
|
82
|
+
function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
|
|
83
|
+
const trimmed = text.trim();
|
|
84
|
+
if (!trimmed) return "failed";
|
|
85
|
+
const body = stripOneClosingFence(trimmed);
|
|
86
|
+
if (!body) return "failed";
|
|
61
87
|
let depth = 0;
|
|
62
88
|
let inString = false;
|
|
63
89
|
let escape = false;
|
|
64
|
-
let
|
|
65
|
-
let
|
|
66
|
-
let
|
|
67
|
-
for (let i = 0; i <
|
|
68
|
-
const ch =
|
|
90
|
+
let currentStart = -1;
|
|
91
|
+
let objectStart = -1;
|
|
92
|
+
let objectEnd = -1;
|
|
93
|
+
for (let i = 0; i < body.length; i++) {
|
|
94
|
+
const ch = body[i];
|
|
69
95
|
if (inString) {
|
|
70
96
|
if (escape) {
|
|
71
97
|
escape = false;
|
|
@@ -83,7 +109,7 @@ function lastBalancedJsonObject(text: string): string | undefined {
|
|
|
83
109
|
continue;
|
|
84
110
|
}
|
|
85
111
|
if (ch === "{") {
|
|
86
|
-
if (depth === 0)
|
|
112
|
+
if (depth === 0) currentStart = i;
|
|
87
113
|
depth++;
|
|
88
114
|
continue;
|
|
89
115
|
}
|
|
@@ -91,59 +117,22 @@ function lastBalancedJsonObject(text: string): string | undefined {
|
|
|
91
117
|
if (depth === 0) continue;
|
|
92
118
|
depth--;
|
|
93
119
|
if (depth === 0) {
|
|
94
|
-
|
|
95
|
-
|
|
120
|
+
objectStart = currentStart;
|
|
121
|
+
objectEnd = i;
|
|
96
122
|
}
|
|
97
123
|
}
|
|
98
124
|
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// A standalone object is one balanced `{...}` covering the entire trimmed text.
|
|
104
|
-
function standaloneObjectText(text: string): string | undefined {
|
|
105
|
-
const trimmed = text.trim();
|
|
106
|
-
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
|
|
107
|
-
const slice = lastBalancedJsonObject(trimmed);
|
|
108
|
-
return slice === trimmed ? slice : undefined;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function outcomeOfObjectText(text: string): string | undefined {
|
|
112
|
-
const slice = standaloneObjectText(text);
|
|
113
|
-
if (slice === undefined) return undefined;
|
|
125
|
+
if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
|
|
126
|
+
let result: unknown;
|
|
114
127
|
try {
|
|
115
|
-
|
|
116
|
-
if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
|
|
117
|
-
const outcome = (result as Record<string, unknown>).outcome;
|
|
118
|
-
return typeof outcome === "string" ? outcome : undefined;
|
|
128
|
+
result = JSON.parse(body.slice(objectStart, objectEnd + 1));
|
|
119
129
|
} catch {
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function isClosingFence(line: string): boolean {
|
|
125
|
-
return /^(`{3,}|~{3,})\s*$/.test(line.trim());
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// The whole reply is one JSON object, or the object stands alone on the final
|
|
129
|
-
// line. A closing code fence after that line is allowed. Prose after the
|
|
130
|
-
// object, an ambiguous tail, a truncated reply, and a missing or disallowed
|
|
131
|
-
// outcome settle failed.
|
|
132
|
-
function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
|
|
133
|
-
const trimmed = text.trim();
|
|
134
|
-
if (!trimmed) return "failed";
|
|
135
|
-
const whole = outcomeOfObjectText(trimmed);
|
|
136
|
-
if (whole !== undefined) return allowedOutcomes.includes(whole) ? whole : "failed";
|
|
137
|
-
const lines = trimmed.split(/\r?\n/);
|
|
138
|
-
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
|
|
139
|
-
if (lines.length > 0 && isClosingFence(lines[lines.length - 1]!)) {
|
|
140
|
-
lines.pop();
|
|
141
|
-
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
|
|
130
|
+
return "failed";
|
|
142
131
|
}
|
|
143
|
-
if (
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
return
|
|
132
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
|
|
133
|
+
const outcome = (result as Record<string, unknown>).outcome;
|
|
134
|
+
if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
|
|
135
|
+
return outcome;
|
|
147
136
|
}
|
|
148
137
|
|
|
149
138
|
export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}): KxmOneShotProducer {
|