Compare commits

..

120 Commits

Author SHA1 Message Date
Bek
b3da987afe perf(memory): add qmd search diagnostics cache 2026-06-24 23:26:01 -04:00
Vincent Koc
643410c1f3 test(qa): scope fanout marker proof to channel runtime 2026-06-25 10:20:51 +08:00
Vincent Koc
8d4e40d293 test(qa): extend fanout marker wait 2026-06-25 10:20:51 +08:00
Vincent Koc
068ae4eb4b test(qa): allow Codex fanout completion window 2026-06-25 10:20:51 +08:00
Vincent Koc
dad7168c2f fix(qa): align runtime parity evidence with Codex 2026-06-25 10:20:51 +08:00
Vincent Koc
31a65e0647 fix(agents): preserve absent embedded session keys 2026-06-25 10:20:51 +08:00
Vincent Koc
1a04b8eb98 test(plugins): review channel daemon spawn findings 2026-06-25 03:42:44 +02:00
clawsweeper[bot]
a21144d8a6 fix(cron): preserve enabled-with-defaults failure alert through store roundtrip (fixes #96589) (AI-assisted) (#96615)
Summary:
- The PR preserves `failure_alert_disabled === 0` as the enabled-with-defaults failure-alert state and adds focused codec roundtrip tests.
- PR surface: Source +2, Tests +54. Total +56 across 2 files.
- Reproducibility: yes. At source level, current main encodes `failureAlert: {}` with `failure_alert_disabled = 0`, then decodes it as `undefined` when all explicit alert option columns are null.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head bd9b2a1798.
- Required merge gates passed before the squash merge.

Prepared head SHA: bd9b2a1798
Review: https://github.com/openclaw/openclaw/pull/96615#issuecomment-4794949533

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Approved-by: takhoffman
2026-06-25 01:17:31 +00:00
Dallin Romney
8a5cb85c31 ci: default maturity evidence to all profile (#96595) 2026-06-24 17:32:25 -07:00
Dallin Romney
61d4ff782e docs: clarify maturity scorecard scoring (#96594)
* docs: clarify maturity scorecard scoring

* chore: split qa profile workflow change

* docs: keep maturity coverage values stable

* test: keep maturity renderer fixture in core boundary
2026-06-24 17:32:08 -07:00
Vincent Koc
3ab7a72764 fix(plugin-sdk): update surface budget 2026-06-25 02:22:44 +02:00
Peter Lee
b4bdea0d02 fix(agent): emit model.usage diagnostic for HTTP ingress traffic (#96152)
* fix(agents): emit model.usage diagnostic for HTTP ingress traffic

* fix(agents): emit model.usage diagnostic for HTTP ingress traffic

* fix(agent): add regression tests and refactor ingress model.usage diagnostic emission

* fix(agent): resolve oxlint curly and no-useless-fallback-in-spread violations
2026-06-24 20:17:28 -04:00
Sarah Fortune
113d6f3c64 fix: surface provider authentication failures in channels (#96599)
* fix: surface provider authentication failures in channels

* fix: handle typed provider auth failures

---------

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
2026-06-24 17:16:30 -07:00
joshavant
0a14444924 Bound successful provider response reads 2026-06-24 19:08:22 -05:00
Peter Lee
0a042f68df fix(agent): replace self-wait with deferred release in retained-lock abort cleanup (#96100)
* fix(agent): wait for retained session write before releasing held lock on abort

* fix(agent): replace self-wait with deferred release in retained-lock abort cleanup

* fix(test): reject fallback acquire with SessionWriteLockTimeoutError in active-scope cleanup test

* fix(agent): trim retained-lock comments

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-24 19:50:58 -04:00
Vincent Koc
3ab8d6aa60 fix(e2e): extend Codex on-demand install timeout 2026-06-25 00:32:29 +02:00
Omar Shahine
f2af052cee perf(imessage): show typing sooner for slow replies (#95621)
Merged via squash.

Prepared head SHA: 65e9ad10fd
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
2026-06-24 15:31:48 -07:00
joshavant
c6f5725906 fix(openshell): pin local mirror fs mutations 2026-06-24 17:03:30 -05:00
Isaiah Stapleton
f47fb91d29 fix(plugins): stop ClawHub version install from inheriting latest compatibility (#96506)
* fix(plugins): stop ClawHub version install from inheriting latest compatibility

When installing a specific older version of a ClawHub plugin, the
compatibility check fell back to the package-level compatibility
metadata when the version-specific response lacked it. The package-level
field reflects the latest version's requirements, so installing e.g.
version 2026.6.8 would incorrectly require OpenClaw >= 2026.6.10.

Remove the fallback to package-level compatibility in
resolveCompatiblePackageVersion(). If a version's artifact response has
no compatibility data, treat it as having no restrictions rather than
inheriting the latest version's constraints.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: IsaiahStapleton <istaplet@redhat.com>

* fix(plugins): narrow compatibility fallback to latest version only

Preserve package-level compatibility enforcement for unpinned/latest
ClawHub installs when the version response omits compatibility data.
Only suppress the fallback for older pinned versions where the
package-level metadata reflects the latest version's requirements.

Add regression test proving unpinned latest installs still reject
incompatible hosts via the package-level compatibility guard.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: IsaiahStapleton <istaplet@redhat.com>

* fix(plugins): recover version-specific compatibility from version endpoint

When the artifact endpoint returns sparse metadata (no compatibility)
for a pinned older version, fetch the version endpoint to get the real
version-specific compatibility data. This preserves compatibility
enforcement for pinned versions instead of treating sparse artifact
metadata as unrestricted.

The fallback chain is now: artifact compatibility -> version endpoint
compatibility -> package-level compatibility (latest only).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: IsaiahStapleton <istaplet@redhat.com>

* fix(plugins): fail closed when version compatibility recovery fails

When the artifact endpoint returns sparse metadata and the version
endpoint is unavailable (transient 500, network failure, etc.), return
the error instead of proceeding with no compatibility checks. This
prevents incompatible plugins from installing when metadata recovery
fails.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: IsaiahStapleton <istaplet@redhat.com>

---------

Signed-off-by: IsaiahStapleton <istaplet@redhat.com>
2026-06-24 17:57:49 -04:00
Dallin Romney
15bfadf2bd fix: count maturity coverage ids (#96543) 2026-06-24 14:56:51 -07:00
joshavant
1d172637d6 fix(cron): omit failed webhook output summaries 2026-06-24 16:52:48 -05:00
Alix-007
dad5ce64d4 fix(providers): bound self-hosted provider discovery JSON reads (#95244)
* fix(providers): bound self-hosted discovery JSON reads

discoverLlamaCppRuntimeContextTokens and discoverOpenAICompatibleLocalModels
parsed their HTTP responses via an unbounded await response.json(). Self-hosted
provider base URLs are user-supplied and untrusted (an endpoint reachable via
SSRF could stream an unbounded JSON body), so a hostile or buggy endpoint could
drive the setup wizard into OOM.

Route both reads through the shared byte-bounded reader (readResponseWithLimit
from @openclaw/media-core) under a single 4 MiB cap before JSON.parse, mirroring
the bound-stream hardening landed for Anthropic error bodies. Overflow cancels
the stream and is swallowed by the existing discovery error handling, so a
capped endpoint degrades gracefully (returns [] / skips the runtime context
probe) instead of buffering the whole body.

* tune self-hosted discovery cap

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-24 17:51:14 -04:00
joshavant
170bf72e64 fix: gate diagnostics command to owners 2026-06-24 16:40:50 -05:00
joshavant
ad5a26cf69 Require owner gate for MCP chat command 2026-06-24 16:37:32 -05:00
joshavant
259877dccf docs: require OpenProse remote import consent 2026-06-24 16:36:48 -05:00
joshavant
d8ee630b20 Harden agent diagnostic redaction 2026-06-24 16:14:32 -05:00
Josh Lehman
2c714ac2e0 fix(whatsapp): route group activation through session accessor (#96530) 2026-06-24 13:46:26 -07:00
Josh Lehman
0cdb050bac test: import model-run prune helper 2026-06-24 13:42:39 -07:00
Josh Lehman
fab0048d7b fix: preserve plugin maintenance config compatibility 2026-06-24 13:42:39 -07:00
Josh Lehman
4a7659920c chore: drop unrelated formatting churn 2026-06-24 13:42:39 -07:00
Josh Lehman
070996e5c3 fix: keep model-run pruning internal 2026-06-24 13:42:39 -07:00
wanglu241
af8cd23f17 fix(sessions): keep plugin SDK maintenanceConfig backward-compatible
The model-run maintenance fields (modelRunPruneAfterMs from #88632 base work,
modelRunPruneAfterConfigured from the pressure-gating fix) were required on the
resolved maintenance config exposed to plugins via patchSessionEntry's
maintenanceConfig. External plugin TypeScript callers that construct a
pre-#88632 maintenanceConfig would fail to compile.

Make both fields optional on ResolvedSessionMaintenanceConfig (and the runtime
type), so old-shape plugin configs keep compiling. All internal readers already
treat an absent value as unset: shouldRunModelRunPrune returns false when
modelRunPruneAfterMs == null and modelRunPruneAfterConfigured is falsy, so a
plugin-supplied config without the fields runs no model-run pruning — the
pre-#88632 behavior. The resolver still always populates both fields, so normal
runtime behavior is unchanged. Add an old-shape maintenanceConfig SDK
regression test.
2026-06-24 13:42:39 -07:00
wanglu241
2fe50f69db fix(sessions): align forced model-run prune with cap eviction
Forced maintenance (sessions cleanup / maintenanceOverride) caps immediately to
maxEntries, but the unset model-run default was high-water gated. In the
(maxEntries, high-water) window stale model-run probes survived while the forced
cap evicted real sessions — the inverse of #88632. shouldRunModelRunPrune now
takes a force flag: when the caller caps immediately, the unset default prunes
once entryCount > maxEntries. Wire force at the two forced call sites
(applyEnforcedMaintenance, previewStoreCleanup). Make the SDK runtime config
field modelRunPruneAfterConfigured optional (additive). Add force-gate unit
test + forced-apply regression test.
2026-06-24 13:42:39 -07:00
wanglu241
fc198d862a Gate default model-run session pruning 2026-06-24 13:42:39 -07:00
wanglu241
2ddedad1d0 fix(sessions): tighten gateway model-run key predicate
The model-run prune predicate fell back to testing the raw sessionKey
when parseAgentSessionKey returned null, so unscoped keys like
`explicit:model-run-<uuid>` and shapes with empty agent ids were
eligible for the new default 24h cleanup. Restrict matching to keys
that successfully parse as agent-scoped with a non-empty agent id,
and add negative tests covering unscoped, empty-agent, extra-segment,
and whitespace-padded keys.

Refs #88632 (review feedback before merge).
2026-06-24 13:42:39 -07:00
wanglu241
33d0019eaf fix(config): validate model-run session retention 2026-06-24 13:42:39 -07:00
wanglu241
875e26e4bb fix(sessions): prune stale gateway model-run sessions 2026-06-24 13:42:39 -07:00
Shakker
d23977edbc test: stabilize runtime context env 2026-06-24 21:04:45 +01:00
Shakker
10e03f797e fix: isolate crestodian first run env 2026-06-24 20:59:14 +01:00
Shakker
f0f5da0e39 test: keep commitments client boundary 2026-06-24 20:52:00 +01:00
Shakker
9777c68563 fix: isolate commitments docker state 2026-06-24 20:50:53 +01:00
Shakker
6d0306b920 test: centralize status env fixture 2026-06-24 20:48:54 +01:00
Josh Lehman
d716900929 refactor: route voice call agent runs through session target (#96539) 2026-06-24 12:48:39 -07:00
Shakker
e2d282f16e fix: route gateway option env mutations 2026-06-24 20:44:01 +01:00
Vincent Koc
9514faca27 chore(sdk): refresh plugin SDK API baseline 2026-06-24 21:40:41 +02:00
Shakker
3848b9619f test: centralize mcp gateway env cleanup 2026-06-24 20:35:50 +01:00
Shakker
365279b86f fix: route temp home env setup 2026-06-24 20:32:41 +01:00
Shakker
1adc076148 test: trim rescue channel fixture imports 2026-06-24 20:28:42 +01:00
Shakker
a49816ffbb fix: route rescue channel env lifecycle 2026-06-24 20:27:53 +01:00
Shakker
fa6a9509bc test: isolate crestodian state env 2026-06-24 20:25:05 +01:00
Shakker
9d82906f79 fix: route transcript fixture state env 2026-06-24 20:23:09 +01:00
Agustin Rivera
3168987b28 fix(exec): gate versioned inline interpreters (#96216)
* fix(exec): gate versioned inline interpreters

* fix(exec): trim unsupported R inline flag

* fix(exec): avoid r2 inline eval collision
2026-06-24 12:22:49 -07:00
Josh Lehman
7e2b2d2987 refactor: migrate bundled session metadata reads (#96527) 2026-06-24 12:19:53 -07:00
Dallin Romney
8670d28126 Target changed lint checks (#94708)
* chore: target changed lint checks

* docs: tighten changed lint guidance

* chore: clean changed lint planner
2026-06-24 12:18:57 -07:00
Shakker
c561319708 test: centralize plugin temp home env 2026-06-24 20:14:51 +01:00
Shakker
387ef7ebc4 fix: route install env test mutations 2026-06-24 20:10:42 +01:00
Shakker
00b6f49b24 test: harden env helper lifecycle coverage 2026-06-24 20:05:47 +01:00
Shakker
c81fec0370 fix: centralize isolated test env writes 2026-06-24 20:01:36 +01:00
Shakker
eac1d3349c test: narrow profile state directory type 2026-06-24 19:57:39 +01:00
Shakker
aa56abc94a fix: share profile gateway env restoration 2026-06-24 19:57:39 +01:00
Josh Lehman
b6bc3ed0db fix: Codex turns stop showing typing during tool work (#95844)
* fix: bridge harness execution phases to typing

* docs: clarify typing indicator activity triggers
2026-06-24 11:50:09 -07:00
Shakker
9ad959a870 test: route trajectory export env setup 2026-06-24 19:44:48 +01:00
Shakker
f2bc159b79 fix: route Codex harness fixture env setup 2026-06-24 19:42:47 +01:00
Josh Lehman
4c841ac575 refactor: remove Telegram session deps adapter (#96524)
* refactor: remove telegram session deps adapter

* test: update telegram session ratchet expectation
2026-06-24 11:37:19 -07:00
Shakker
8ecbf83c67 test: route CLI backend fixture env setup 2026-06-24 19:32:12 +01:00
Shakker
3fbdbb5440 fix: route ACP spawn fixture env setup 2026-06-24 19:29:48 +01:00
Josh Lehman
da50a450d2 fix(memory-core): route dreaming corpus through session corpus metadata (#96517) 2026-06-24 11:29:26 -07:00
Shakker
ff332d3819 test: share Codex bind env lifecycle 2026-06-24 19:23:55 +01:00
Shakker
c2d2f7fef9 fix: route ACP bind state env setup 2026-06-24 19:20:07 +01:00
Vincent Koc
6df67285df fix(sdk): keep surface budgets tight 2026-06-25 02:18:07 +08:00
Vincent Koc
5eec2158ea fix(sdk): restore post-build surface budgets 2026-06-25 02:10:39 +08:00
Vincent Koc
d01c290601 test(sdk): assert surface budget growth guard 2026-06-25 02:05:06 +08:00
Vincent Koc
d3cfef3bd8 fix(media-understanding): align video base64 byte limits (#96519)
Merged via squash.

Prepared head SHA: e37e577cd4
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 02:02:59 +08:00
Vincent Koc
f163d778c0 fix(sdk): tighten public surface budgets 2026-06-25 01:47:50 +08:00
Vincent Koc
b302b491da fix(sdk): refresh public surface budgets 2026-06-25 01:41:04 +08:00
Josh Lehman
4d4769c0d6 refactor(path3): narrow bundled session runtime barrels (#96507) 2026-06-24 10:33:40 -07:00
linhongkuan
f57a30289d fix(media-understanding): strip repeated placeholders (#96431)
Merged via squash.

Prepared head SHA: 4b004863b1
Co-authored-by: lin-hongkuan <234943746+lin-hongkuan@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 01:25:08 +08:00
Agustin Rivera
bcbd521c1b fix(gateway): cap auth limiter entries (#96224) 2026-06-24 10:22:57 -07:00
Drew Meyer
94ab33036e fix(discord): avoid duplicate typing keepalive for tool replies (#84288)
Co-authored-by: Andrew Meyer <andrewmeyer@andrews-air.lan>
2026-06-25 01:22:18 +08:00
linhongkuan
47d3d1b1f1 fix(media-core): normalize GIF content type detection (#96435)
Merged via squash.

Prepared head SHA: 82b139664b
Co-authored-by: lin-hongkuan <234943746+lin-hongkuan@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 01:09:10 +08:00
Milosz Jankiewicz
0347ae48ea fix(xai): rediscover retired OAuth token endpoint (#96146)
Merged via squash.

Prepared head SHA: 7ea3195fbf
Co-authored-by: Jaaneek <25470423+Jaaneek@users.noreply.github.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Reviewed-by: @fuller-stack-dev
2026-06-24 11:05:18 -06:00
Colin Johnson
4ae0a5d958 ci: run QA smoke profile in CI (#94291)
* ci: add qa lab smoke profile dispatch

* ci: prove qa lab smoke profile on prs

* ci: preserve manual qa lab profile dispatch

* ci: run qa lab smoke profile on pull requests

* ci: keep QA smoke mock lane bounded

* ci: run QA smoke PR proof through crabline

* ci: keep mock QA timeouts on caller fallbacks

* ci: prebuild QA smoke runtime

* ci: delegate smoke QA evidence workflow

* ci: trust release branch smoke evidence refs

* ci: trim smoke evidence workflow comments

* ci: align smoke evidence wrapper with QA profile contract

* ci: keep smoke profile evidence mock-only

* ci: make smoke profile evidence manual

* ci: shard qa smoke profile in ci

* ci: drop qa-channel-only smoke shard

* ci: derive qa smoke shards from taxonomy

* ci: keep qa smoke planner legacy-safe

* ci: enforce qa smoke shard failures

* ci: run qa smoke in existing fast shard

* ci: opt qa smoke into crabline concurrency

* test(ci): align qa smoke guard with taxonomy cleanup

* ci: split qa smoke into dedicated check

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-24 09:47:45 -07:00
Gio Della-Libera
c5f10b5f7c Doctor: expose config audit scrub findings (#84450)
* feat(doctor): expose config audit scrub findings

* fix(doctor): keep audit scrub lint opt-in

* fix(doctor): keep audit lint defaults internal

* fix(doctor): remove duplicate lint default guard
2026-06-24 09:34:28 -07:00
Dallin Romney
f29dbd3ebd test(qa): speed up smoke profile (#96340) 2026-06-24 09:30:59 -07:00
xingzhou
3217165be7 fix(telegram): preserve inline buttons for empty capabilities (#96468)
Merged via squash.

Prepared head SHA: 5e55b5dd30
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 00:09:45 +08:00
Vincent Koc
dbe2802cdc fix(sdk): refresh API baseline hash 2026-06-25 00:04:34 +08:00
ly-wang19
5f25651fd9 fix(ui): roll usage-metrics formatTokens over to "M" at the 999,950 boundary (#96450)
Merged via squash.

Prepared head SHA: fe9881afe7
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 00:03:16 +08:00
joshavant
d7c69da6a6 docs(ios): document live activity review flow 2026-06-24 11:00:04 -05:00
joshavant
e77994ed5a fix(ios): clarify camera purpose string 2026-06-24 11:00:04 -05:00
ly-wang19
db3307b02a fix(canvas): stop self-closing embed from starting a greedy block match (#96449)
Merged via squash.

Prepared head SHA: 7253bb298e
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:46:20 +08:00
linhongkuan
6b1755aa2b fix(media-core): accept unpadded inline base64 images (#96437)
Merged via squash.

Prepared head SHA: dc4693b7bf
Co-authored-by: lin-hongkuan <234943746+lin-hongkuan@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:44:05 +08:00
Yufeng He
fa2379dbc8 fix(telegram): clip progress text on code-point boundaries to avoid lone surrogates (#96456)
Merged via squash.

Prepared head SHA: 765d6c08ac
Co-authored-by: he-yufeng <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:27:59 +08:00
Galin Iliev
ce6d97d580 fix(plugins): suppress metadata cache hit scan spans (#86796)
Merged via squash.

Prepared head SHA: a4907bf285
Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:09:12 +08:00
Alix-007
d1c2934d0d fix(ollama): bound model-discovery JSON response reads (#96027)
* fix(ollama): bound model-discovery JSON response reads

The /api/tags and /api/show discovery reads in extensions/ollama/src/provider-models.ts
parsed their HTTP responses with an unbounded await response.json(). Ollama base URLs
are user-supplied and can point at remote/cloud endpoints, so a hostile or buggy server
(or one reachable via SSRF) could stream an unbounded or never-ending JSON body and drive
model discovery into OOM.

Route both reads through the shared @openclaw/media-core byte-bounded reader
(readResponseWithLimit, re-exported via openclaw/plugin-sdk/response-limit-runtime) under
a single 16 MiB cap before JSON.parse, cancelling the stream on overflow. Overflow throws a
bounded error that the existing fail-soft handlers swallow, so a capped endpoint degrades
gracefully: /api/tags returns { reachable: false, models: [] } and /api/show returns {}.

Symmetric counterpart to the #95103/#95108 response-limit campaign.

AI-assisted.

* fix(ollama): reuse shared bounded JSON reader for model discovery

Replace the local readOllamaDiscoveryJson helper with the shared
readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which
already enforces the 16 MiB cap, cancels the stream on overflow, and wraps
malformed JSON with the caller label. The /api/tags and /api/show discovery
reads now go through it directly while keeping the existing fail-soft
handlers ({ reachable: false, models: [] } and {}).

Add a focused regression test: when a discovery stream exceeds the JSON byte
cap, fetchOllamaModels returns { reachable: false, models: [] },
queryOllamaModelShowInfo returns {}, and the bounded reader cancels the body
mid-flight so less than the full advertised stream is read.
2026-06-24 10:58:13 -04:00
Alix-007
605aede38c fix(exa): bound untrusted search JSON response reads (#96038)
Exa search success responses were read via an unbounded `await
response.json()`, so a misbehaving or hostile endpoint could stream an
arbitrarily large body into memory before parsing. Read the success
body through the shared bounded reader (16 MiB cap, the same limit other
bundled providers use) and cancel the stream on overflow. This mirrors
the error-body bound already in place and the #95103/#95108 response
-limit campaign on the success-JSON side.

AI-assisted.
2026-06-24 10:57:37 -04:00
Alix-007
6163b1977b fix(parallel): bound successful web-search JSON response reads (#96035)
* fix(parallel): bound successful web-search JSON response reads

The Parallel web_search provider parsed its /v1/search success body with an
unbounded await res.json(). The body comes from an external web-search
upstream, so a hostile or malfunctioning endpoint streaming an unbounded JSON
payload could force the runtime to buffer the whole response before parsing,
creating memory pressure or a hang on the provider path.

Read the success body through the shared readProviderJsonResponse helper with a
16 MiB cap (matching the provider JSON cap from #95218); on overflow the stream
is cancelled and a bounded error is thrown. The error-body path was already
bounded (readResponseTextLimited, 8 KiB). Symmetric follow-up to the
#95103/#95108 response-limit campaign.

* docs(parallel): drop upstream PR ref from response-cap comment

Replace the PR-specific '#95218' annotation with a neutral description of
the shared provider JSON cap so the comment stays accurate independent of
upstream PR numbering.
2026-06-24 10:57:24 -04:00
Vincent Koc
eabc12b7d6 fix(sandbox): install supported node in common image 2026-06-24 22:54:00 +08:00
Josh Lehman
b58e6e0734 refactor: route session status through session accessors (#96460) 2026-06-24 07:44:19 -07:00
Vincent Koc
d83cd282c6 fix(qa): record checked-out ref in evidence (#96434)
Merged via squash.

Prepared head SHA: 86b3df6e59
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:37:41 +08:00
狼哥
374076b5a8 fix(plugins): retain plugin tool registry after replacement (#82562)
Merged via squash.

Prepared head SHA: 1bcbbbfbc1
Co-authored-by: luoyanglang <238804951+luoyanglang@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:22:29 +08:00
杨浩宇0668001029
242fbf1a67 test(telegram): pass outbound sanitizer payload 2026-06-24 07:13:32 -07:00
杨浩宇0668001029
434d752dd6 fix(telegram): sanitize outbound tool traces 2026-06-24 07:13:32 -07:00
Ayaan Zaidi
3179692f0e fix(messages): apply response usage to followups 2026-06-24 07:12:33 -07:00
Peter Lindsey
6add1cc969 feat(messages): config-level default for the persistent /usage footer
Adds `messages.responseUsage` (precedence session -> channel -> config default
-> off) so the persistent /usage footer can default-on, with three distinct
states: explicit on (tokens/full), explicit off (persisted), and unset (inherit
the configured default).

Unifies effective-value resolution behind a single channel-aware resolver
`resolveEffectiveResponseUsage` used by reply rendering, the no-arg /usage
toggle, the ACP control, and the gateway session-row builder; the row builder's
`effectiveResponseUsage` is carried through sessions.changed events, chat
snapshots, and the UI row so live consumers never go stale. `/usage reset`
(aliases inherit/clear/default) clears the override to inherit; only explicit
off persists; a full session reset preserves the preference. ACP "Usage detail"
gains an "inherit" option for unset sessions. Docs/help/completions updated; "on"
documented as a legacy alias; config-doc baseline regenerated.
2026-06-24 07:12:33 -07:00
ly-wang19
cb13be375d fix(tasks): preserve both cron-run session key shapes during maintenance (#96352)
* fix(tasks): preserve both cron-run session key shapes during maintenance

Session-registry maintenance keeps running cron jobs' session rows, but
readRunningCronJobIds built the preserve-set with job.id.toLowerCase() only.
Cron-run session keys carry two job-segment shapes: main-session runs use the
slugified segment (normalizeCronLaneSegment, e.g. "daily-report") while
default-isolated runs use the raw lowercased id ("daily report", built from
cron:${job.id} via toAgentStoreSessionKey, which lowercases but does not
slugify). The lowercase-only matcher preserved isolated runs but pruned
main-session runs of any non-slug job id (e.g. "Daily Report") as stale.

Preserve both shapes (raw lowercased id and slugified segment). This is
strictly more-preserving, so no live running cron session is dropped. Adds a
regression test seeding both a slug main-session run and a raw isolated run for
a non-slug job id, asserting both survive while a non-running job's run is still
pruned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tasks): match cron session keys to target shape

* fix(tasks): preserve active cron aliases across retargeting

* fix(tasks): retain explicit cron session aliases

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 22:10:49 +08:00
Josh Lehman
acc2a0ee72 refactor: route boot session mapping through accessor (#96225) 2026-06-24 06:54:19 -07:00
Gio Della-Libera
704fc35043 Doctor: expose session lock findings (#84366)
Merged via squash.

Prepared head SHA: 93192bb7ab
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-24 06:53:01 -07:00
Ayaan Zaidi
f1e38f2ed6 fix(telegram): narrow rich table alignment surface 2026-06-24 06:41:38 -07:00
zhang-guiping
d2933bbdb9 fix(telegram): refresh rich table SDK budget 2026-06-24 06:41:38 -07:00
张贵萍0668001030
2e124081af fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
张贵萍0668001030
8150b76b6f fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
张贵萍0668001030
77eb0fdbaa fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
ly-wang19
f0be8e7b6e fix(duckduckgo): decode &amp; last in decodeHtmlEntities to avoid double-decoding (#96348)
* fix(duckduckgo): decode &amp; last in decodeHtmlEntities to avoid double-decoding

decodeHtmlEntities decoded &amp; FIRST, so result text that literally contains
an entity (e.g. a page title 'How to escape &lt; in HTML', which DuckDuckGo
returns double-encoded as '&amp;lt;') was re-decoded into markup: '&amp;lt;'
became '<' instead of the literal '&lt;', corrupting the titles, snippets, and
URLs the web-search tool returns to the model.

Reorder so &amp; is decoded last, matching the established convention elsewhere
in the codebase (msteams/inbound.ts, openai-transport-stream.ts,
launchd-plist.ts, doctor-session-snapshots.ts all decode &amp; last).
Behavior-preserving for all singly-encoded input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(duckduckgo): decode html entities in one pass

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 21:35:27 +08:00
ly-wang19
80bd0003ce fix(msteams): decode &amp; last in stripHtmlFromTeamsMessage to avoid double-decoding (#96342)
stripHtmlFromTeamsMessage decoded &amp; FIRST, so literal entity text the
user typed (which Microsoft Graph returns double-encoded, e.g. &amp;lt;) got
re-decoded into markup: "The token is &amp;lt;APIKEY&amp;gt;" became
"The token is <APIKEY>" instead of the correct "The token is &lt;APIKEY&gt;".

Reorder so &amp; is decoded last, mirroring the documented ordering in
decodeHtmlEntities (inbound.ts), whose comment already states it 'must be last
to prevent double-decoding (e.g. &amp;lt; -> &lt; not <)'. Behavior-preserving
for all singly-encoded input; the existing entity test is unchanged.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:34:40 +08:00
snowzlmbot
f3891e1335 fix(context-engine): avoid quarantining read-only discovery factories (#96357)
* fix(context-engine): ignore read-only discovery factories

* fix(context-engine): keep discovery registrations out of runtime probes

---------

Co-authored-by: snowzlmbot <snowzlmbot@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 21:33:49 +08:00
ly-wang19
bea3d292c7 fix(memory-core): keep short protected-glossary terms past the min-length gate (#96304)
PROTECTED_GLOSSARY exists to preserve short technical terms that generic
filtering would discard, but every glossary match still flowed through
normalizeConceptToken's per-script minimum-length gate. The 2-char latin
entries "kv" and "s3" were therefore never emitted as concept tags despite
being on the protect-list. Thread a fromGlossary flag so glossary matches
bypass only that length check; all other gates still apply.

Because that bypass lets short entries through, a bare substring match would
also surface them from inside longer words ("kv" in "mkv", "s3" in "css3").
Match ONLY the short entries (those below their script's min length) as
delimiter-bounded whole tokens; longer entries keep substring containment, so
the shipped behavior of "backup" tagging inside "backups" is preserved. CJK
entries (no word delimiters) always use substring matching. Positive
(standalone kv/s3) and negative (mkv/css3 substrings) regression tests cover
both directions, and the short-term-promotion stable-tags assertion gains "s3".

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:32:58 +08:00
Ayaan Zaidi
17066f2d7c fix(cron): preserve default toolsAllow markers safely 2026-06-24 06:26:52 -07:00
Cameron Beeley
9aea104cc8 fix(cron): stop stamping an unenforceable default toolsAllow cap on CLI runs
#91499 auto-stamps the creator's tool surface as a default toolsAllow cap
on agentTurn cron payloads whenever the creating session is tool-restricted
(a narrowing allow-policy or an explicit deny). CLI backends cannot enforce
a runtime toolsAllow — cli-runner/prepare.ts rejects any defined allow-list
— so every scheduled agentTurn that resolves to a CLI backend (e.g.
claude-cli) fails to start. This silently broke per-thread scheduled
continuations on CLI backends.

A CLI backend is not a runtime tool-policy boundary: it runs with its own
configured tool set, as the operator, on the local machine, and refuses a
runtime allow-list outright. An inherited default cap is therefore
unenforceable on a CLI backend. Decide at run time, where the backend is
known:

- Flag the default. capCronAgentTurnToolsAllow stamps toolsAllowIsDefault
  when it fills in the creator surface because the cron requested nothing
  (or a bare "*"). An explicit narrowing or empty allow-list is a real
  per-cron restriction and carries no flag.
- Drop only the default, only on CLI. The run-executor drops a flagged
  default in the CLI branch and lets the run proceed. An explicit per-cron
  restriction (no flag) is deliberately passed through, so prepare.ts still
  fails it closed and surfaces that the requested policy needs an embedded
  runtime. Embedded runs are untouched and keep the full cap enforced.
- Persist the flag. New nullable cron_jobs.payload_tools_allow_is_default
  column (additive ensureColumn migration + codec read/write) so the
  decision survives a gateway restart, plus toolsAllowIsDefault on the
  gateway-protocol agentTurn payload schema — the stamped payload is
  otherwise rejected by the contract's additionalProperties:false.
- Preserve the flag across updates. A no-toolsAllow update (reschedule,
  prompt edit) no longer carries the stored default forward as a literal
  value — that routed it through the explicit-narrowing branch, stripped the
  flag, and re-broke the job on CLI after the next restart. The default is
  re-derived (flag intact); an explicit restriction is still carried forward
  unflagged.

Net policy: on CLI only the unenforceable inherited default is relaxed;
explicit per-cron restrictions still fail closed; embedded backends are
unchanged.

Tests: run-executor drops the flagged default but propagates an explicit
restriction on CLI; cron-tool stamps/clears the flag across create and
update and preserves it across a no-toolsAllow update; store round-trips the
flag (and its absence) through SQLite.

Not covered: agentTurn crons created during the regression window carry a
flagless toolsAllow and remain fail-closed on CLI until recreated or updated
with an explicit toolsAllow.
2026-06-24 06:26:52 -07:00
Ayaan Zaidi
2aa9d67635 refactor(telegram): simplify rich email entity detection 2026-06-24 06:23:08 -07:00
Kelaw - Keshav's Agent
51eec3a757 fix(telegram): skip rich entity detection for oauth emails 2026-06-24 06:23:08 -07:00
402 changed files with 14647 additions and 5607 deletions

View File

@@ -251,7 +251,6 @@ jobs:
],
};
});
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
@@ -285,6 +284,7 @@ jobs:
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "QA Smoke CI", runtime: "node", task: "qa-smoke-ci" },
{ check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" },
);
} else {
@@ -922,6 +922,26 @@ jobs:
pnpm test:bundled
pnpm protocol:check
;;
qa-smoke-ci)
output_dir=".artifacts/qa-e2e/smoke-ci-profile"
export OPENCLAW_BUILD_PRIVATE_QA=1
export OPENCLAW_ENABLE_PRIVATE_QA_CLI=1
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=0
export OPENCLAW_QA_REDACT_PUBLIC_METADATA=1
export OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS=180000
NODE_OPTIONS=--max-old-space-size=8192 node scripts/build-all.mjs qaRuntime
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile smoke-ci \
--concurrency 8 \
--output-dir "$output_dir" || qa_exit_code=$?
echo "QA smoke profile evidence: \`${output_dir}\`" >> "$GITHUB_STEP_SUMMARY"
if [ "$qa_exit_code" -ne 0 ]; then
echo "::error title=QA smoke profile failed::smoke-ci exited ${qa_exit_code}; evidence upload will still run"
exit "$qa_exit_code"
fi
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
@@ -938,6 +958,15 @@ jobs:
;;
esac
- name: Upload QA smoke profile evidence
if: always() && matrix.task == 'qa-smoke-ci'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: qa-smoke-profile-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/smoke-ci-profile/
if-no-files-found: warn
retention-days: 7
checks-fast-plugin-contracts-shard:
permissions:
contents: read

View File

@@ -134,7 +134,7 @@ jobs:
with:
ref: ${{ inputs.ref }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
qa_profile: all
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -238,8 +238,8 @@ jobs:
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
@@ -256,8 +256,8 @@ jobs:
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
@@ -428,14 +428,14 @@ jobs:
cat > "$body_file" <<BODY
## Summary
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and release QA evidence
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and full taxonomy QA evidence
- maturity source ref: ${REF_INPUT}
- QA evidence run: ${evidence_run_id}
## Verification
- QA Lab maturity score validation passed
- Maturity scorecard workflow rendered docs from release profile qa-evidence.json artifacts with strict inputs
- Maturity scorecard workflow rendered docs from all profile qa-evidence.json artifacts with strict inputs
BODY
pr_url="$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""')"

View File

@@ -18,7 +18,7 @@ on:
qa_profile:
description: Taxonomy QA profile id to run (for example release or all)
required: true
default: release
default: all
type: string
workflow_call:
inputs:

View File

@@ -57,11 +57,10 @@ jobs:
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
PACKAGES="ca-certificates" \
INSTALL_PNPM=0 \
INSTALL_BUN=0 \
INSTALL_BREW=0 \
FINAL_USER=sandbox \
scripts/sandbox-common-setup.sh
u="$(timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
test "$u" = "sandbox"
timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc \
'set -e; test "$(id -un)" = sandbox; node --version; pnpm --version'

View File

@@ -118,11 +118,11 @@ Skills own workflows; root owns hard policy and routing.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
- Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change.
## Validation

View File

@@ -105,6 +105,19 @@ Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Live Activity / Dynamic Island
1. Tap `Settings`.
2. Tap `Reconnect`.
3. Immediately send OpenClaw to the background by returning to the Home Screen
or locking the iPhone.
4. Watch the Lock Screen or Dynamic Island while the Gateway reconnects.
Expected result: while reconnecting, iOS can show an `OpenClaw` Live Activity
with connection status such as `Connecting...` or `Reconnecting...`. On a fast
network this status may be brief because OpenClaw ends the Live Activity after
the Gateway reconnects successfully.
## Push Notification
1. Tap the `Chat` tab.

View File

@@ -57,7 +57,7 @@
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
<key>NSCameraUsageDescription</key>
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
<string>OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace.</string>
<key>NSContactsUsageDescription</key>
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
<key>NSLocalNetworkUsageDescription</key>

View File

@@ -156,7 +156,7 @@ targets:
NSAllowsLocalNetworking: true
NSBonjourServices:
- _openclaw-gw._tcp
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
NSCameraUsageDescription: OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace.
NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access.

View File

@@ -1,4 +1,4 @@
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json
b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
212b76ef72779add8f18be4848e143e61b6ae42a1c7daeefdc42d91e0a1152e9 plugin-sdk-api-baseline.json
976179e09e9e46a9b9259bd20ca1cafc8883c8e281a099a9aaa5fceab3c2983b plugin-sdk-api-baseline.jsonl
ea7c5c6dc96594843238bdc8674e0f03041a61445d6e2d0ab82c30c9ce832f91 plugin-sdk-api-baseline.json
65282a8e00237c16745670e2583a289349be1dbd1a0d395789da9dceb1538cf9 plugin-sdk-api-baseline.jsonl

View File

@@ -579,7 +579,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
</Accordion>
<Accordion title="Read receipts and typing">
When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with:
When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with:
```json5
{

View File

@@ -30,7 +30,7 @@ or an explicit manual dispatch.
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, QA Smoke CI, and CI-routing checks | Node-relevant changes |
| `checks-fast-contracts-plugins-*` | Two sharded plugin contract checks | Node-relevant changes |
| `checks-fast-contracts-channels-*` | Two sharded channel contract checks | Node-relevant changes |
| `checks-node-core-*` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |

View File

@@ -120,6 +120,7 @@ openclaw sessions cleanup --json
- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run history, which is managed by `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
- Cleanup reports short-lived gateway model-run probe cleanup separately as `modelRunPruned`. This only matches strict explicit keys shaped like `agent:*:explicit:model-run-<uuid>`. The fixed retention is `24h`, but it is pressure-gated: it only removes stale probe rows when session-entry maintenance/cap pressure is reached. When it runs, model-run cleanup happens before global stale cleanup and capping.
- `--dry-run`: preview how many entries would be pruned/capped without writing.
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) plus a summary grouped by session label so you can see what would be kept vs removed.

View File

@@ -127,6 +127,14 @@ in `enforce` mode and applies cleanup during maintenance. Set
For production-sized `maxEntries` limits, Gateway runtime writes use a small high-water buffer and clean back down to the configured cap in batches. Session store reads do not prune or cap entries during Gateway startup. This avoids running full store cleanup on every startup or isolated cron session. `openclaw sessions cleanup --enforce` applies the cap immediately.
Gateway model-run probe sessions are short-lived by default. Matching rows with
strict explicit keys like `agent:*:explicit:model-run-<uuid>` use fixed `24h`
retention, but cleanup is pressure-gated: it only removes stale probe rows when
session-entry maintenance/cap pressure is reached. When model-run cleanup runs,
it runs before the broader stale-entry age cutoff and entry cap. Normal direct,
group, thread, cron, hook, heartbeat, ACP, and sub-agent sessions do not inherit
this 24h retention.
Maintenance preserves durable external conversation pointers, including group
sessions and thread-scoped chat sessions, while still allowing synthetic cron,
hook, heartbeat, ACP, and sub-agent entries to age out.

View File

@@ -15,7 +15,8 @@ When `agents.defaults.typingMode` is **unset**, OpenClaw keeps the legacy behavi
- **Direct chats**: typing starts immediately once the model loop begins.
- **Group chats with a mention**: typing starts immediately.
- **Group chats without a mention**: typing starts only when message text begins streaming.
- **Group chats without a mention**: typing starts when the admitted run has
user-visible activity, such as harness execution activity or message text.
- **Heartbeat runs**: typing starts when the heartbeat run begins if the
resolved heartbeat target is a typing-capable chat and typing is not disabled.
@@ -26,13 +27,14 @@ Set `agents.defaults.typingMode` to one of:
- `never` - no typing indicator, ever.
- `instant` - start typing **as soon as the model loop begins**, even if the run
later returns only the silent reply token.
- `thinking` - start typing on the **first reasoning delta** (requires
`reasoningLevel: "stream"` for the run).
- `message` - start typing on the **first non-silent text delta** (ignores
the `NO_REPLY` silent token).
- `thinking` - start typing on the **first reasoning delta** or on active
harness execution after the turn is accepted.
- `message` - start typing on the **first user-visible reply activity**, such as
active harness execution or a non-silent text delta. Silent reply tokens such
as `NO_REPLY` do not count as text activity.
Order of "how early it fires":
`never``message``thinking``instant`
`never``message`/`thinking``instant`
## Configuration
@@ -62,11 +64,10 @@ Override mode or cadence per session:
## Notes
- `message` mode won't show typing for silent-only replies when the whole
payload is the exact silent token (for example `NO_REPLY` / `no_reply`,
matched case-insensitively).
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
If the model doesn't emit reasoning deltas, typing won't start.
- `message` mode does not start from silent reply tokens, but active execution
can still show typing before any assistant text is available.
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`),
and it can also start from active execution before reasoning deltas arrive.
- Heartbeat typing is a liveness signal for the resolved delivery target. It
starts at heartbeat run start instead of following `message` or `thinking`
stream timing. Set `typingMode: "never"` to disable it.

View File

@@ -30,6 +30,68 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Default usage footer mode
`/usage off|tokens|full` sets the footer for a session and is remembered for that
session. `messages.responseUsage` seeds that mode for sessions that have not
chosen one, so the footer can be on by default without typing `/usage` each time.
Set one mode for every channel, or a per-channel map with a `default` fallback:
```jsonc
{
"messages": {
"responseUsage": "tokens",
// or: { "default": "off", "discord": "full" }
},
}
```
### Three distinct session states
A session's `responseUsage` field has three representable states, each with
different semantics:
| State | Stored value | Effective mode |
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
### Precedence
Effective mode = session override → channel config entry → `default``off`.
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
session, not the same as "unset." This means a non-off `messages.responseUsage`
default cannot turn the footer back on once the user has explicitly disabled it.
### Resetting vs. turning off
- `/usage off` — forces the footer off and persists that choice. A configured
non-off default cannot override this.
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
override. The session then **inherits** the effective config default
(`messages.responseUsage`). If no default is configured, the footer is off
(unchanged from before). Use this to "go back to default" without explicitly
turning the footer on.
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
the explicit usage-mode preference so the user's display choice survives
session rollovers. Only `/usage reset` (and its aliases) actually clears the
override.
### Toggle behavior
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
for the cycle is the **effective** current mode (session override falling through
to the config default when unset), so the cycle is always consistent with what
the user sees in the footer.
### Config
With no config the prior behavior holds (footer off until `/usage`). Use
`/usage reset` to clear a session override and re-inherit the configured default.
## Custom `/usage full` footer
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,

View File

@@ -1316,6 +1316,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only.
- `pruneAfter`: age cutoff for stale entries (default `30d`).
- `maxEntries`: maximum number of entries in `sessions.json` (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately.
- Short-lived gateway model-run probe sessions use fixed `24h` retention, but cleanup is pressure-gated: it only removes stale strict model-run probe rows when session-entry maintenance/cap pressure is reached. Only strict explicit probe keys matching `agent:*:explicit:model-run-<uuid>` are eligible; normal direct, group, thread, cron, hook, heartbeat, ACP, and sub-agent sessions do not inherit this 24h retention. When model-run cleanup runs, it runs before the broader `pruneAfter` stale-entry cleanup and `maxEntries` cap.
- `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs.
- `resetArchiveRetention`: retention for `*.reset.<timestamp>` transcript archives. Defaults to `pruneAfter`; set `false` to disable.
- `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first.

View File

@@ -415,7 +415,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
</Step>
<Step title="Optional: build the common image">
For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`):
For a more functional sandbox image with common tooling (for example `curl`, `jq`, Node 24, pnpm, `python3`, and `git`):
From a source checkout:

File diff suppressed because it is too large Load Diff

View File

@@ -308,7 +308,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Normal setup and repair paths are documented across install, CLI, and gateway docs. Platform-specific Windows paths are tracked in the Windows via WSL2 and Native Windows rows.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 2%</span><span>Quality Stable - 83%</span><span>Completeness Stable - 90%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 6</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 4%</span><span>Quality Stable - 83%</span><span>Completeness Stable - 90%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 6</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -317,7 +317,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">CLI Setup</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>17%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "17%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/install/index), [Installer](/install/installer), [Node](/install/node), [Updating](/install/updating)</div>
@@ -327,7 +327,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Onboarding and Auth Setup</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Onboard](/cli/onboard), [Configure](/cli/configure), [Onboarding Overview](/start/onboarding-overview)</div>
@@ -337,7 +337,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Plugin and Channel Setup</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Onboard](/cli/onboard), [Plugins](/cli/plugins), [Channels](/cli/channels)</div>
@@ -347,7 +347,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Gateway Service Management</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>14%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "14%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>87%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "87%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Gateway](/cli/gateway), [Updating](/install/updating), [Troubleshooting](/gateway/troubleshooting)</div>
@@ -357,7 +357,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">CLI Observability</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Status](/cli/status), [Health](/cli/health), [Logs](/cli/logs), [Diagnostics](/gateway/diagnostics)</div>
@@ -367,7 +367,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Doctor</span>
<span>10 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Doctor](/cli/doctor), [Doctor](/gateway/doctor), [Secrets](/gateway/secrets), [Troubleshooting](/gateway/troubleshooting)</div>
@@ -377,7 +377,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Updates and Upgrades</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Updating](/install/updating), [Update](/cli/update), [Troubleshooting](/gateway/troubleshooting)</div>
@@ -391,7 +391,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Core architecture, auth, pairing, protocol docs, daemon docs, and CLI runbooks are broad and current.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 3%</span><span>Quality Stable - 81%</span><span>Completeness Stable - 89%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 12</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 6%</span><span>Quality Stable - 81%</span><span>Completeness Stable - 89%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 12</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -400,7 +400,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Approvals and Remote Execution</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Index](/gateway/security/index)</div>
@@ -410,7 +410,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">HTTP APIs</span>
<span>4 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>25%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "25%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/gateway/index), [Openai Http Api](/gateway/openai-http-api), [Openresponses Http Api](/gateway/openresponses-http-api), [Tools Invoke Http Api](/gateway/tools-invoke-http-api), [Hooks](/automation/hooks), [Index](/web/index)</div>
@@ -420,7 +420,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Hosted Web Surface</span>
<span>4 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/gateway/index), [Architecture](/concepts/architecture), [Control Ui](/web/control-ui), [Webchat](/web/webchat), [Canvas](/refactor/canvas)</div>
@@ -430,7 +430,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Gateway RPC APIs and Events</span>
<span>20 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>9%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "9%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Index](/gateway/index), [Architecture](/concepts/architecture)</div>
@@ -440,7 +440,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Device Auth and Pairing</span>
<span>10 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Pairing](/gateway/pairing), [Index](/gateway/security/index)</div>
@@ -450,7 +450,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Network Access and Discovery</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/gateway/index), [Discovery](/gateway/discovery), [Protocol](/gateway/protocol)</div>
@@ -460,7 +460,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Nodes and Remote Capabilities</span>
<span>8 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Architecture](/concepts/architecture), [Index](/nodes/index)</div>
@@ -470,7 +470,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Health, Diagnostics, and Repair</span>
<span>7 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/gateway/index), [Diagnostics](/gateway/diagnostics), [Doctor](/gateway/doctor)</div>
@@ -480,7 +480,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Protocol Compatibility</span>
<span>7 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Architecture](/concepts/architecture), [Typebox](/concepts/typebox), [Bridge Protocol](/gateway/bridge-protocol)</div>
@@ -490,7 +490,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Roles and Permissions</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Index](/gateway/security/index)</div>
@@ -500,7 +500,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Gateway Lifecycle</span>
<span>7 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>33%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "33%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/gateway/index), [Architecture](/concepts/architecture)</div>
@@ -510,7 +510,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Security Controls</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>75%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "75%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>89%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "89%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/gateway/security/index), [Protocol](/gateway/protocol), [Discovery](/gateway/discovery)</div>
@@ -520,7 +520,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">WebSocket Connection</span>
<span>8 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>13%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "13%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-stable"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-stable">Stable</span><span>90%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "90%" }} /></span></span></div>
<div className="maturity-category-docs">[Protocol](/gateway/protocol), [Architecture](/concepts/architecture)</div>
@@ -534,7 +534,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Main loop, models, provider routing, and tool streaming are first-class, but provider behavior shifts weekly and needs scenario proof per release.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 2%</span><span>Quality Beta - 78%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 6</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 33%</span><span>Quality Beta - 78%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 6</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -543,7 +543,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Agent Turn Execution</span>
<span>3 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>29%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "29%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Agent Loop](/concepts/agent-loop), [Agent](/cli/agent), [Agent Runtimes](/concepts/agent-runtimes)</div>
@@ -553,7 +553,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">External Runtimes and Subagents</span>
<span>4 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>30%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "30%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Agent Runtimes](/concepts/agent-runtimes), [Anthropic](/providers/anthropic), [Google](/providers/google), [Subagents](/tools/subagents)</div>
@@ -563,7 +563,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Hosted Provider Execution</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>20%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "20%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Openai](/providers/openai), [Anthropic](/providers/anthropic), [Google](/providers/google), [Models](/concepts/models)</div>
@@ -573,7 +573,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Local and Self-hosted Providers</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Ollama](/providers/ollama), [Models](/concepts/models), [Agent](/cli/agent)</div>
@@ -583,7 +583,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Model and Runtime Selection</span>
<span>4 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>25%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "25%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Models](/concepts/models), [Models](/cli/models), [Openai](/providers/openai), [Agent Runtimes](/concepts/agent-runtimes)</div>
@@ -593,7 +593,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Provider Auth</span>
<span>10 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>24%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "24%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Models](/concepts/models), [Agent](/cli/agent), [Models](/cli/models), [Openai](/providers/openai), [Anthropic](/providers/anthropic), [Google](/providers/google), [Subagents](/tools/subagents)</div>
@@ -603,7 +603,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Streaming and Progress</span>
<span>2 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>56%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "56%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Streaming](/concepts/streaming), [Agent Loop](/concepts/agent-loop)</div>
@@ -613,7 +613,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Tool Calls and Response Handling</span>
<span>3 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>65%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "65%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Agent Loop](/concepts/agent-loop), [Ollama](/providers/ollama)</div>
@@ -623,7 +623,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Tool Execution Controls</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>50%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "50%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Sandbox Vs Tool Policy Vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated), [Agent Loop](/concepts/agent-loop), [Subagents](/tools/subagents)</div>
@@ -637,7 +637,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Strong docs and active implementation. Maturity depends on transcript durability, compaction quality, and cross-client parity.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 0%</span><span>Quality Beta - 77%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 6</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 30%</span><span>Quality Beta - 77%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 6</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -656,7 +656,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Token Management</span>
<span>3 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>20%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "20%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Compaction](/concepts/compaction), [Context](/concepts/context), [Session Management Compaction](/reference/session-management-compaction)</div>
@@ -666,7 +666,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Context Engine</span>
<span>2 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>57%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "57%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Context](/concepts/context), [Context Engine](/concepts/context-engine), [Codex Context Engine Harness](/plan/codex-context-engine-harness)</div>
@@ -676,7 +676,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Cross-client History and Session Parity</span>
<span>2 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>40%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "40%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Webchat](/web/webchat), [Android](/platforms/android), [Channel Routing](/channels/channel-routing)</div>
@@ -686,7 +686,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Diagnostics, Maintenance, and Recovery</span>
<span>3 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>40%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "40%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Diagnostics](/gateway/diagnostics), [Session Management Compaction](/reference/session-management-compaction), [Flags](/diagnostics/flags)</div>
@@ -696,7 +696,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Core Prompts and Context</span>
<span>2 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>38%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "38%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Context](/concepts/context), [Transcript Hygiene](/reference/transcript-hygiene), [Discord](/channels/discord)</div>
@@ -706,7 +706,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Memory</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>46%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "46%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Memory Config](/reference/memory-config), [Memory Qmd](/concepts/memory-qmd), [Memory](/concepts/memory), [Discord](/channels/discord)</div>
@@ -716,7 +716,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Session Routing</span>
<span>2 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>25%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "25%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Session](/concepts/session), [Channel Routing](/channels/channel-routing), [Discord](/channels/discord)</div>
@@ -740,7 +740,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Many channels share Gateway delivery and routing contracts, but channel behavior varies by upstream API and account-policy constraints.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 0%</span><span>Quality Beta - 76%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 5</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 13%</span><span>Quality Beta - 76%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 5</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -759,7 +759,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Channel Setup</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>14%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "14%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/channels/index), [Pairing](/channels/pairing), [Troubleshooting](/channels/troubleshooting), [Sdk Channel Plugins](/plugins/sdk-channel-plugins)</div>
@@ -769,7 +769,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Group Thread and Ambient Room Behavior</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>36%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "36%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Groups](/channels/groups), [Group Messages](/channels/group-messages), [Ambient Room Events](/channels/ambient-room-events), [Broadcast Groups](/channels/broadcast-groups), [Discord](/channels/discord)</div>
@@ -799,7 +799,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Outbound Delivery and Reply Pipeline</span>
<span>4 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>38%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "38%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Groups](/channels/groups), [Ambient Room Events](/channels/ambient-room-events), [Discord](/channels/discord), [Matrix](/channels/matrix), [Config Channels](/gateway/config-channels)</div>
@@ -809,7 +809,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Conversation Routing and Delivery</span>
<span>10 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>19%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "19%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Channel Routing](/channels/channel-routing), [Groups](/channels/groups), [Discord](/channels/discord), [Matrix](/channels/matrix), [Troubleshooting](/channels/troubleshooting), [Configuration Reference](/gateway/configuration-reference)</div>
@@ -833,7 +833,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
OTel, Prometheus, logging, and diagnostics docs exist. Needs a public "what operators should look at first" maturity pass.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 6%</span><span>Quality Beta - 75%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 3</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 18%</span><span>Quality Beta - 75%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 3</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -842,7 +842,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Health and Repair</span>
<span>12 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>6%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "6%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>28%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "28%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Health](/gateway/health), [Telegram](/channels/telegram), [Doctor](/cli/doctor), [Doctor](/gateway/doctor), [Sdk Subpaths](/plugins/sdk-subpaths), [Health](/cli/health), [Protocol](/gateway/protocol)</div>
@@ -852,7 +852,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Logging</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>6%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "6%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Logging](/logging), [Logging](/gateway/logging), [Logs](/cli/logs)</div>
@@ -862,7 +862,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Diagnostic Collection</span>
<span>8 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>6%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "6%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>30%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "30%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Diagnostics](/gateway/diagnostics), [Health](/gateway/health), [Codex Harness](/plugins/codex-harness), [Protocol](/gateway/protocol)</div>
@@ -872,7 +872,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Telemetry Export</span>
<span>13 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>6%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "6%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>33%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "33%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Hooks](/plugins/hooks), [Opentelemetry](/gateway/opentelemetry), [Logging](/logging), [Sdk Subpaths](/plugins/sdk-subpaths), [Diagnostics Otel](/plugins/reference/diagnostics-otel), [Prometheus](/gateway/prometheus), [Diagnostics Prometheus](/plugins/reference/diagnostics-prometheus)</div>
@@ -882,7 +882,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Session Diagnostics</span>
<span>4 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>6%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "6%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Opentelemetry](/gateway/opentelemetry), [Prometheus](/gateway/prometheus), [Diagnostics](/gateway/diagnostics), [Protocol](/gateway/protocol)</div>
@@ -896,7 +896,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Web UI is documented with pairing, chat, PWA, Talk, push, and remote Gateway flows. Promote after cross-browser and mobile-PWA scorecards.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 0%</span><span>Quality Beta - 74%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 4%</span><span>Quality Beta - 74%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -935,7 +935,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Browser UI</span>
<span>10 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Control Ui](/web/control-ui), [Index](/web/index), [Dashboard](/web/dashboard), [Protocol](/gateway/protocol)</div>
@@ -945,7 +945,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">WebChat Conversations</span>
<span>15 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>10%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "10%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Control Ui](/web/control-ui), [Webchat](/web/webchat), [Getting Started](/start/getting-started), [Channel Routing](/channels/channel-routing), [Secure File Operations](/gateway/security/secure-file-operations)</div>
@@ -955,7 +955,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Operator Console</span>
<span>10 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Control Ui](/web/control-ui), [Health](/gateway/health), [Protocol](/gateway/protocol), [Dashboard](/web/dashboard)</div>
@@ -969,7 +969,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Broad docs and strong internal runtime evidence exist across manifests, discovery, loading, provider/tool architecture, and approval boundaries. Keep the row at beta until public SDK API/subpaths and external distribution proof are stronger.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 2%</span><span>Quality Beta - 72%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 7</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 12%</span><span>Quality Beta - 72%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 7</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -978,7 +978,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Authoring and Packaging plugins</span>
<span>8 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Building Plugins](/plugins/building-plugins), [Sdk Overview](/plugins/sdk-overview), [Sdk Entrypoints](/plugins/sdk-entrypoints), [Sdk Subpaths](/plugins/sdk-subpaths), [Manifest](/plugins/manifest), [Reference](/plugins/reference)</div>
@@ -988,7 +988,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Bundled plugins</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Plugin Inventory](/plugins/plugin-inventory), [Plugins](/cli/plugins), [Architecture Internals](/plugins/architecture-internals)</div>
@@ -998,7 +998,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Canvas plugin</span>
<span>6 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Canvas](/plugins/reference/canvas), [Canvas](/refactor/canvas), [Configuration Reference](/gateway/configuration-reference)</div>
@@ -1008,7 +1008,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Installing and running plugins</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>35%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "35%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Architecture](/plugins/architecture), [Architecture Internals](/plugins/architecture-internals), [Plugins](/cli/plugins)</div>
@@ -1018,7 +1018,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Channel plugins</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Sdk Channel Plugins](/plugins/sdk-channel-plugins), [Sdk Channel Inbound](/plugins/sdk-channel-inbound), [Sdk Channel Outbound](/plugins/sdk-channel-outbound)</div>
@@ -1028,7 +1028,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Provider and tool plugins</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>43%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "43%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Sdk Provider Plugins](/plugins/sdk-provider-plugins), [Tool Plugins](/plugins/tool-plugins), [Adding Capabilities](/plugins/adding-capabilities)</div>
@@ -1038,7 +1038,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Plugin approvals</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Plugin Permission Requests](/plugins/plugin-permission-requests), [Exec Approvals](/tools/exec-approvals), [Sdk Channel Plugins](/plugins/sdk-channel-plugins)</div>
@@ -1048,7 +1048,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Publishing plugins</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Plugins](/cli/plugins), [Compatibility](/plugins/compatibility), [Publishing](/clawhub/publishing)</div>
@@ -1058,7 +1058,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Testing plugins</span>
<span>6 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>2%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "2%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>27%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "27%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Sdk Testing](/plugins/sdk-testing), [Sdk Setup](/plugins/sdk-setup), [Codex Harness](/plugins/codex-harness)</div>
@@ -1072,7 +1072,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Good docs and hardening surfaces exist. Promote after regular upgrade/security scenario runs prove no setup regressions.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 0%</span><span>Quality Beta - 72%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 5</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 16%</span><span>Quality Beta - 72%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 5</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -1081,7 +1081,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Approval Policy and Tool Safeguards</span>
<span>2 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>50%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "50%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Exec Approvals](/tools/exec-approvals), [Approvals](/cli/approvals), [Plugin Permission Requests](/plugins/plugin-permission-requests), [Audit Checks](/gateway/security/audit-checks)</div>
@@ -1131,7 +1131,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Credential and Secret Hygiene</span>
<span>5 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>46%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "46%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Authentication](/gateway/authentication), [Models](/cli/models), [Openai](/providers/openai), [Oauth](/concepts/oauth), [Secrets](/gateway/secrets), [Secrets](/cli/secrets), [Secretref Credential Surface](/reference/secretref-credential-surface), [Audit Checks](/gateway/security/audit-checks)</div>
@@ -1145,7 +1145,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Documented and usable, but scenario proof should cover unattended delivery, retries, and failure visibility.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 0%</span><span>Quality Beta - 72%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 2%</span><span>Quality Beta - 72%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -1194,7 +1194,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Heartbeat</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>14%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "14%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Index](/automation/index), [Heartbeat](/gateway/heartbeat), [Commitments](/concepts/commitments)</div>
@@ -1218,7 +1218,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Broad capability surface exists, but provider variance, file limits, and node/app parity make this not stable yet.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 1%</span><span>Quality Alpha - 64%</span><span>Completeness Alpha - 68%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 2%</span><span>Quality Alpha - 64%</span><span>Completeness Alpha - 68%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -1227,7 +1227,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Media Intake and Access</span>
<span>8 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>1%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "1%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>61%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "61%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div className="maturity-category-docs">[Media Overview](/tools/media-overview), [Media Understanding](/nodes/media-understanding), [Secure File Operations](/gateway/security/secure-file-operations), [Pdf](/tools/pdf), [Image Generation](/tools/image-generation), [Qr](/cli/qr), [Line](/channels/line), [Whatsapp](/channels/whatsapp)</div>
@@ -1237,7 +1237,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Channel Media Handling</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>1%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "1%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>61%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "61%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div className="maturity-category-docs">[Images](/nodes/images), [Media Overview](/tools/media-overview), [Discord](/channels/discord)</div>
@@ -1247,7 +1247,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Media Configuration</span>
<span>1 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>1%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "1%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>61%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "61%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div className="maturity-category-docs">[Media Overview](/tools/media-overview), [Image Generation](/tools/image-generation), [Manifest](/plugins/manifest), [Codex Harness](/plugins/codex-harness)</div>
@@ -1257,7 +1257,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Text-to-Speech Delivery</span>
<span>2 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>1%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "1%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>61%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "61%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div className="maturity-category-docs">[Tts](/tools/tts), [Media Overview](/tools/media-overview), [Discord](/channels/discord)</div>
@@ -1267,7 +1267,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Media Understanding</span>
<span>12 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>1%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "1%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>7%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "7%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>69%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "69%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>69%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "69%" }} /></span></span></div>
<div className="maturity-category-docs">[Audio](/nodes/audio), [Media Understanding](/nodes/media-understanding), [Media Overview](/tools/media-overview), [Whatsapp](/channels/whatsapp), [Images](/nodes/images), [Infer](/cli/infer), [Pdf](/tools/pdf)</div>
@@ -1277,7 +1277,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Media Generation</span>
<span>17 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>1%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "1%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>5%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "5%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>69%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "69%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>69%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "69%" }} /></span></span></div>
<div className="maturity-category-docs">[Image Generation](/tools/image-generation), [Media Overview](/tools/media-overview), [Skills](/tools/skills), [Music Generation](/tools/music-generation), [Video Generation](/tools/video-generation)</div>
@@ -1480,7 +1480,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
OpenClaw App SDK is a distinct external app contract separate from Gateway runtime and Plugin SDK. Current scoring shows a real `@openclaw/sdk` path with gaps around public packaging, auto-discovery, approvals, helpers, and compatibility.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 0%</span><span>Quality Alpha - 54%</span><span>Completeness Alpha - 53%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 3%</span><span>Quality Alpha - 54%</span><span>Completeness Alpha - 53%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -1529,7 +1529,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Resource Helpers</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>17%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "17%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>62%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "62%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>53%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "53%" }} /></span></span></div>
<div className="maturity-category-docs">[Openclaw Sdk](/gateway/external-apps), [Openclaw Sdk Api Design](/gateway/external-apps)</div>
@@ -1704,7 +1704,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Install docs exist and are common deployment paths. Promote after recurring release smoke captures upgrade and volume behavior.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 5%</span><span>Quality Beta - 71%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 7%</span><span>Quality Beta - 71%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -1713,7 +1713,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Container Setup</span>
<span>6 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>5%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "5%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Docker](/install/docker), [Podman](/install/podman)</div>
@@ -1723,7 +1723,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Container Operations</span>
<span>11 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>5%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "5%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Podman](/install/podman), [Docker Vm Runtime](/install/docker-vm-runtime), [Docker](/install/docker), [Hetzner](/install/hetzner), [Hostinger](/install/hostinger)</div>
@@ -1733,7 +1733,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Image Release and Validation</span>
<span>5 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>5%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "5%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>29%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "29%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Docker](/install/docker), [Docker Vm Runtime](/install/docker-vm-runtime), [Full Release Validation](/reference/full-release-validation)</div>
@@ -1743,7 +1743,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Agent Sandbox and Tooling</span>
<span>3 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>5%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "5%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Docker](/install/docker), [Docker Vm Runtime](/install/docker-vm-runtime)</div>
@@ -1757,7 +1757,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Recommended Windows path with systemd/user-service guidance and boot-chain docs. Promote after repeated install/update scorecards.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 3%</span><span>Quality Alpha - 69%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 5</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 6%</span><span>Quality Alpha - 69%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 5</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -1766,7 +1766,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">WSL Setup</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Windows](/platforms/windows), [Getting Started](/start/getting-started)</div>
@@ -1776,7 +1776,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">CLI</span>
<span>8 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Windows](/platforms/windows), [Getting Started](/start/getting-started), [Updating](/install/updating), [Onboard](/cli/onboard), [Doctor](/cli/doctor), [Status](/cli/status), [Logs](/cli/logs)</div>
@@ -1786,7 +1786,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Gateway Service Lifecycle</span>
<span>10 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Windows](/platforms/windows), [Index](/gateway/index), [Doctor](/gateway/doctor)</div>
@@ -1796,7 +1796,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Gateway Access and Exposure</span>
<span>11 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Authentication](/gateway/authentication), [Secrets](/gateway/secrets), [Remote](/gateway/remote), [Exposure Runbook](/gateway/security/exposure-runbook), [Windows](/platforms/windows)</div>
@@ -1806,7 +1806,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Diagnostics and Repair</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>38%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "38%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Windows](/platforms/windows), [Status](/cli/status), [Logs](/cli/logs), [Doctor](/cli/doctor), [Doctor](/gateway/doctor)</div>
@@ -1816,7 +1816,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Browser and Control UI</span>
<span>6 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>3%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "3%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Browser Wsl2 Windows Remote Cdp Troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting), [Browser](/tools/browser), [Control Ui](/web/control-ui)</div>
@@ -3276,7 +3276,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Core tools are documented, but host security and permission UX should stay under active scorecard review.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 15%</span><span>Quality Beta - 75%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 2</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 21%</span><span>Quality Beta - 75%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 2</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -3285,7 +3285,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Browser Automation</span>
<span>8 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>15%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "15%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>13%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "13%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Browser Control](/tools/browser-control), [Testing](/help/testing), [Browser](/tools/browser), [Index](/gateway/security/index), [Audit Checks](/gateway/security/audit-checks)</div>
@@ -3295,7 +3295,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Tool Invocation and Execution</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>15%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "15%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>50%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "50%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Exec](/tools/exec), [Background Process](/gateway/background-process), [Tools Invoke Http Api](/gateway/tools-invoke-http-api), [Operator Scopes](/gateway/operator-scopes), [Protocol](/gateway/protocol), [Exec Approvals](/tools/exec-approvals), [Exec Approvals Advanced](/tools/exec-approvals-advanced), [Elevated](/tools/elevated)</div>
@@ -3305,7 +3305,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Sandbox and Tool Policy</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>15%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "15%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Sandboxing](/gateway/sandboxing), [Sandbox Vs Tool Policy Vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated), [Multi Agent Sandbox Tools](/tools/multi-agent-sandbox-tools), [Codex Harness Reference](/plugins/codex-harness-reference), [Config Tools](/gateway/config-tools)</div>
@@ -3319,7 +3319,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Deep docs, OAuth/subscription path, realtime voice, image, and compatibility behavior. Provider churn keeps this from Stable without release-scorecard proof.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 8%</span><span>Quality Beta - 74%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 3</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 26%</span><span>Quality Beta - 74%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-partial">Partial - 3</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -3328,7 +3328,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Model and Auth</span>
<span>6 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>44%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "44%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Openai](/providers/openai), [Codex Harness](/plugins/codex-harness), [Models](/concepts/models), [Oauth](/concepts/oauth), [Codex Harness Reference](/plugins/codex-harness-reference), [Auth Monitoring](/automation/auth-monitoring)</div>
@@ -3338,7 +3338,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Responses and Tool Compatibility</span>
<span>4 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>40%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "40%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Openai](/providers/openai), [Openresponses Http Api](/gateway/openresponses-http-api), [Openai Http Api](/gateway/openai-http-api), [Codex Native Plugins](/plugins/codex-native-plugins)</div>
@@ -3348,7 +3348,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Native Codex Harness</span>
<span>2 capabilities / LTS-supported</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>44%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "44%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Codex Harness](/plugins/codex-harness), [Codex Harness Runtime](/plugins/codex-harness-runtime), [Codex Harness Reference](/plugins/codex-harness-reference), [Codex Native Plugins](/plugins/codex-native-plugins)</div>
@@ -3358,7 +3358,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Image and Multimodal Input</span>
<span>2 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Openai](/providers/openai), [Image Generation](/tools/image-generation), [Images](/nodes/images)</div>
@@ -3368,7 +3368,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Voice and Realtime Audio</span>
<span>2 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>8%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "8%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>67%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "67%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Openai](/providers/openai), [Discord](/channels/discord), [Voice Call](/plugins/voice-call)</div>
@@ -3382,7 +3382,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
Multiple providers and docs exist. Needs quota/error/SSRF proof per provider family.
<div className="maturity-surface-rollup"><span>Coverage Experimental - 7%</span><span>Quality Beta - 74%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-surface-rollup"><span>Coverage Experimental - 9%</span><span>Quality Beta - 74%</span><span>Completeness Beta - 79%</span><span><span className="maturity-lts maturity-lts-none">None</span></span></div>
<div className="maturity-category-list">
<div className="maturity-category-row maturity-category-row-header"><span>Area</span><span>Coverage</span><span>Quality</span><span>Completeness</span><span>Docs</span></div>
@@ -3391,7 +3391,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Search Providers</span>
<span>19 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>7%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "7%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>11%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "11%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Web](/tools/web), [Brave Search](/tools/brave-search), [Tavily](/tools/tavily), [Exa Search](/tools/exa-search), [Firecrawl](/tools/firecrawl), [Perplexity Search](/tools/perplexity-search), [Duckduckgo Search](/tools/duckduckgo-search), [Searxng Search](/tools/searxng-search), [Gemini Search](/tools/gemini-search), [Grok Search](/tools/grok-search), [Kimi Search](/tools/kimi-search), [Minimax Search](/tools/minimax-search), [Ollama Search](/tools/ollama-search), [Sdk Subpaths](/plugins/sdk-subpaths), [Sdk Overview](/plugins/sdk-overview), [Manifest](/plugins/manifest)</div>
@@ -3401,7 +3401,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Setup and Diagnostics</span>
<span>9 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>7%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "7%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Web](/tools/web), [Web Fetch](/tools/web-fetch), [Faq](/help/faq), [Api Usage Costs](/reference/api-usage-costs), [Brave Search](/tools/brave-search), [Perplexity Search](/tools/perplexity-search), [Tavily](/tools/tavily), [Firecrawl](/tools/firecrawl)</div>
@@ -3411,7 +3411,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Network Safety</span>
<span>4 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>7%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "7%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>0%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "0%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-alpha"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-alpha">Alpha</span><span>68%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "68%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Web](/tools/web), [Web Fetch](/tools/web-fetch), [Firecrawl](/tools/firecrawl), [Searxng Search](/tools/searxng-search)</div>
@@ -3421,7 +3421,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app.
<span className="maturity-category-title">Tool Availability and Fetch</span>
<span>11 capabilities</span>
</div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>7%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "7%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-experimental"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-experimental">Experimental</span><span>25%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "25%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div><span className="maturity-score maturity-score-beta"><span className="maturity-score-label"><span className="maturity-level-pill maturity-level-beta">Beta</span><span>79%</span></span><span className="maturity-meter" aria-hidden="true"><span style={{ width: "79%" }} /></span></span></div>
<div className="maturity-category-docs">[Config Tools](/gateway/config-tools), [Web Fetch](/tools/web-fetch), [Web](/tools/web), [Faq](/help/faq)</div>

View File

@@ -186,12 +186,8 @@ file.
- optional `event.runId`
- optional `event.toolCallId`
- context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`,
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.trigger`,
`ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace`
- for channel-originated calls, origin fields such as `ctx.channel`,
`ctx.messageProvider`, `ctx.channelId`, `ctx.chatId`, `ctx.senderId`, and
extensible `ctx.channelContext` sender/chat metadata. These use the same
identity semantics described below for agent hook contexts.
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.toolKind`,
`ctx.toolInputKind`, and diagnostic `ctx.trace`
It can return:

View File

@@ -71,6 +71,11 @@ OpenProse registers `/prose` as a user-invocable skill command:
`/prose run <handle/slug>` resolves to `https://p.prose.md/<handle>/<slug>`.
Direct URLs are fetched as-is using the `web_fetch` tool.
Top-level remote runs are explicit. Remote imports inside a `.prose` program are
transitive code dependencies: before OpenProse fetches any remote `use` target,
it shows the resolved import list and requires the operator to reply exactly
`approve remote prose imports` for that run.
## What it can do
- Multi-agent research and synthesis with explicit parallelism.
@@ -167,9 +172,12 @@ User-level persistent agents live at:
## Security
Treat `.prose` files like code. Review them before running. Use OpenClaw tool
allowlists and approval gates to control side effects. For deterministic,
approval-gated workflows, compare with [Lobster](/tools/lobster).
Treat `.prose` files like code. Review them before running, including remote
`use` imports. Top-level `/prose run https://...` requests are explicit, but
transitive remote imports require per-run approval before they are fetched or
executed. Use OpenClaw tool allowlists and approval gates to control side
effects. For deterministic, approval-gated workflows, compare with
[Lobster](/tools/lobster).
## Related

View File

@@ -81,6 +81,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
- `mode`: `enforce` (default) or `warn`
- `pruneAfter`: stale-entry age cutoff (default `30d`)
- `maxEntries`: cap entries in `sessions.json` (default `500`)
- Short-lived gateway model-run probe retention is fixed at `24h`, but it is pressure-gated: it only removes stale strict probe rows when session-entry maintenance/cap pressure is reached. This applies only to strict explicit probe keys matching `agent:*:explicit:model-run-<uuid>` and runs before global stale-entry cleanup/capping when it runs.
- `resetArchiveRetention`: retention for `*.reset.<timestamp>` transcript archives (default: same as `pruneAfter`; `false` disables cleanup)
- `maxDiskBytes`: optional sessions-directory budget
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
@@ -90,7 +91,12 @@ Normal Gateway writes flow through a per-store session writer that serializes in
Maintenance keeps durable external conversation pointers such as group sessions
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,
heartbeat, ACP, and sub-agents can still be removed when they exceed the
configured age, count, or disk budget.
configured age, count, or disk budget. Gateway model-run probe sessions use the
separate `24h` model-run retention only when their key exactly matches
`agent:*:explicit:model-run-<uuid>`; other explicit sessions are not part of
that retention. The model-run cleanup is applied only under session-entry cap
pressure. Isolated cron runs keep their own `cron.sessionRetention` control,
independent of model-run probe retention.
OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The legacy `session.maintenance.rotateBytes` key is ignored and `openclaw doctor --fix` removes it from older configs.

View File

@@ -76,6 +76,8 @@ Use these in chat:
configured for the active model.
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
override so the session re-inherits the configured default.
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
local pricing for the active model. Otherwise it shows tokens only.
- `/usage cost` → shows a local cost summary from OpenClaw session logs.

View File

@@ -269,7 +269,7 @@ html.dark .nav-tabs-underline {
.maturity-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
margin: 14px 0 20px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);

View File

@@ -240,7 +240,7 @@ plugins.
| `/tasks` | List active/recent background tasks for the current session |
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
| `/whoami` | Show your sender id. Alias: `/id` |
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
</Accordion>
<Accordion title="Skills, allowlists, approvals">

View File

@@ -126,7 +126,7 @@ Session controls:
- `/verbose <on|full|off>`
- `/trace <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>`
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`

View File

@@ -12,11 +12,7 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildApprovalResponse,
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
} from "./approval-bridge.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
@@ -46,20 +42,6 @@ const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
type ApprovalRequestParams = Parameters<typeof handleCodexAppServerApprovalRequestImpl>[0];
function handleCodexAppServerApprovalRequest(
params: Omit<ApprovalRequestParams, "toolHookContext"> & {
toolHookContext?: ApprovalRequestParams["toolHookContext"];
},
) {
return handleCodexAppServerApprovalRequestImpl({
...params,
toolHookContext:
params.toolHookContext ?? buildCodexToolHookRunContext({ attempt: params.paramsForRun }),
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
@@ -261,8 +243,6 @@ describe("Codex app-server approval bridge", () => {
ctx: {
agentId: "main",
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat-1",
},
});
@@ -1184,18 +1164,11 @@ describe("Codex app-server approval bridge", () => {
});
});
it("uses the caller-resolved hook context for approval fallback policy", async () => {
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
const params = createParams();
params.agentId = "raw-agent";
params.sessionId = "raw-session";
params.sessionKey = "agent:raw:session";
params.runId = "raw-run";
params.messageChannel = "discord";
params.messageProvider = "discord";
params.currentChannelId = "discord:raw-target";
params.jobId = "raw-job";
params.senderId = "raw-user";
params.chatId = "raw-chat";
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.currentChannelId = "telegram:-100123";
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
@@ -1209,27 +1182,6 @@ describe("Codex app-server approval bridge", () => {
command: "pnpm test extensions/codex/src/app-server",
},
paramsForRun: params,
toolHookContext: {
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
},
threadId: "thread-1",
turnId: "turn-1",
});
@@ -1237,29 +1189,11 @@ describe("Codex app-server approval bridge", () => {
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
}),
}),
);
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
});
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {

View File

@@ -8,6 +8,7 @@ import {
*/
import {
type AgentApprovalEventData,
buildAgentHookContextChannelFields,
formatApprovalDisplayPath,
hasNativeHookRelayInvocation,
invokeNativeHookRelay,
@@ -16,7 +17,6 @@ import {
type NativeHookRelayProcessResponse,
type NativeHookRelayRegistrationHandle,
runBeforeToolCallHook,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -75,7 +75,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: string;
requestParams: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: Pick<
@@ -107,7 +106,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
context,
nativeHookRelay: params.nativeHookRelay,
signal: params.signal,
@@ -621,7 +619,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
context: ApprovalContext;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
@@ -655,6 +652,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
if (nativeRelayOutcome?.handled) {
return { outcome: "no-decision" };
}
const hookChannelId = buildAgentHookContextChannelFields({
sessionKey: params.paramsForRun.sessionKey,
messageChannel: params.paramsForRun.messageChannel,
messageProvider: params.paramsForRun.messageProvider,
currentChannelId: params.paramsForRun.currentChannelId,
messageTo: params.paramsForRun.messageTo,
}).channelId;
const outcome = await runBeforeToolCallHook({
toolName: policyRequest.toolName,
params: policyRequest.params,
@@ -662,9 +666,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
approvalMode: "request",
signal: params.signal,
ctx: {
...params.toolHookContext,
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
...(cwd ? { cwd } : {}),
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
...(hookChannelId ? { channelId: hookChannelId } : {}),
},
});
if (outcome.blocked) {

View File

@@ -944,16 +944,8 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "discord:D123";
params.currentChannelId = "D123";
params.currentMessagingTarget = "user:U123";
params.chatId = "chat-123";
params.senderId = "user-123";
params.channelContext = {
sender: { id: "user-123" },
chat: { id: "chat-123" },
};
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
@@ -964,19 +956,9 @@ describe("Codex app-server dynamic tool build", () => {
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
currentChannelId: "discord:D123",
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
chatId: "chat-123",
senderId: "user-123",
hookChannelContext: {
sender: { id: "user-123" },
chat: { id: "chat-123" },
},
});
expect((factoryOptions[0] as { channelContext?: unknown }).channelContext).toBeUndefined();
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {

View File

@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.currentMessagingTarget ?? params.messageTo,
messageTo: params.messageTo,
}).channelId;
}
@@ -239,7 +239,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
elevated: params.bashElevated,
},
sandbox: input.sandbox,
messageChannel: params.messageChannel,
messageProvider: resolveCodexMessageToolProvider(params),
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
agentAccountId: params.agentAccountId,
@@ -250,7 +249,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
hookChannelContext: params.channelContext,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
@@ -292,7 +290,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
),
suppressManagedWebSearch: false,
currentChannelId: params.currentChannelId,
chatId: params.chatId,
currentMessagingTarget: params.currentMessagingTarget,
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
currentThreadTs: params.currentThreadTs,

View File

@@ -1846,17 +1846,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -1960,17 +1949,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -1997,17 +1975,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
expectExecuteCall(execute, { callId: "call-1", args: { command: "pwd", mode: "safe" } });
@@ -2030,17 +1997,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
});

View File

@@ -32,7 +32,6 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -54,8 +53,13 @@ import type {
JsonValue,
} from "./protocol.js";
type CodexDynamicToolHookContext = ToolHookRunContext & {
type CodexDynamicToolHookContext = {
agentId?: string;
config?: EmbeddedRunAttemptParams["config"];
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
@@ -66,7 +70,7 @@ type CodexDynamicToolHookContext = ToolHookRunContext & {
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
type CodexToolResultHookContext = ToolHookRunContext;
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
@@ -306,7 +310,11 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
...toolResultHookContext,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
startArgs: executedArgs,
result,
startedAt,
@@ -399,7 +407,11 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
...toolResultHookContext,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
startArgs: executedArgs,
error: errorMessage,
startedAt,
@@ -690,35 +702,13 @@ function dedupeQuarantinedDynamicTools(
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
const {
agentId,
sessionId,
sessionKey,
runId,
jobId,
trace,
trigger,
messageProvider,
channel,
chatId,
senderId,
channelId,
channelContext,
} = ctx ?? {};
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
return {
...(agentId && { agentId }),
...(sessionId && { sessionId }),
...(sessionKey && { sessionKey }),
...(runId && { runId }),
...(jobId && { jobId }),
...(trace && { trace }),
...(trigger && { trigger }),
...(messageProvider && { messageProvider }),
...(channel && { channel }),
...(chatId && { chatId }),
...(senderId && { senderId }),
...(channelId && { channelId }),
...(channelContext && { channelContext }),
};
}

View File

@@ -2587,36 +2587,15 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const projectorParams = {
const projector = await createProjector({
...(await createParams()),
agentId: "raw-agent",
sessionId: "raw-session",
sessionKey: "agent:raw:session-1",
runId: "raw-run",
};
const projector = await createProjector(projectorParams, {
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1" },
chat: { id: "channel-1" },
},
},
agentId: "main",
sessionKey: "agent:main:session-1",
});
await projector.handleNotification(
@@ -2673,17 +2652,6 @@ describe("CodexAppServerEventProjector", () => {
expect(context.sessionId).toBe("session-1");
expect(context.sessionKey).toBe("agent:main:session-1");
expect(context.runId).toBe("run-1");
expect(context.jobId).toBe("job-1");
expect(context.trigger).toBe("user");
expect(context.messageProvider).toBe("discord-voice");
expect(context.channel).toBe("discord");
expect(context.chatId).toBe("channel-1");
expect(context.senderId).toBe("user-1");
expect(context.channelId).toBe("channel-1");
expect(context.channelContext).toEqual({
sender: { id: "user-1" },
chat: { id: "channel-1" },
});
expect(context.toolName).toBe("bash");
expect(context.toolCallId).toBe("cmd-observed");
});

View File

@@ -18,7 +18,6 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
type ToolProgressDetailMode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -66,7 +65,6 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
toolHookContext?: ToolHookRunContext;
onNativeToolResultRecorded?: () => void | Promise<void>;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -1376,9 +1374,6 @@ export class CodexAppServerEventProjector {
agentId: this.params.agentId,
sessionId: this.params.sessionId,
sessionKey: this.params.sessionKey,
// The attempt boundary resolves aliases and sandbox session identity once.
// Keep that canonical snapshot authoritative over optional raw projector params.
...this.options.toolHookContext,
startArgs: itemToolArgs(item) ?? {},
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -8,7 +8,6 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
addTimerTimeoutGraceMs,
@@ -122,7 +121,6 @@ export function createCodexNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
@@ -148,7 +146,6 @@ export function createCodexNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexNativeHookRelayTtlMs({
explicitTtlMs: params.options?.ttlMs,

View File

@@ -1,6 +1,9 @@
// Codex tests cover run attemptynamic tools plugin behavior.
import path from "node:path";
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
onAgentEvent,
type AgentEventPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
emitTrustedDiagnosticEvent,
onInternalDiagnosticEvent,
@@ -606,21 +609,6 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
}
});
it("prefers the current messaging target for hook channel fallback", () => {
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.messageTo = "telegram:stale-target";
params.currentMessagingTarget = "telegram:current-target";
expect(testing.resolveCodexAppServerHookChannelId(params, "agent:main:session-1")).toBe(
"current-target",
);
});
it("passes normalized channel context to app-server dynamic tool result hooks", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(

View File

@@ -30,7 +30,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -93,15 +95,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "channel:target";
params.trigger = "user";
params.senderId = "user-1";
params.chatId = "native-target";
params.channelContext = {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
};
const run = runCodexAppServerAttempt(params, {
nativeHookRelay: {
@@ -141,22 +135,6 @@ describe("runCodexAppServerAttempt native hook relay", () => {
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
channelId: "target",
chatId: "native-target",
senderId: "user-1",
channelContext: {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
},
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId,

View File

@@ -38,7 +38,6 @@ import {
type EmbeddedRunAttemptResult,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import {
@@ -249,7 +248,6 @@ import {
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import {
inferCodexDynamicToolMeta,
resolveCodexToolProgressDetailMode,
@@ -719,14 +717,6 @@ export async function runCodexAppServerAttempt(
});
}
const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey);
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: params,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
});
preDynamicStartupStages.mark("context-engine-support");
const preDynamicSummary = preDynamicStartupStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(preDynamicSummary)) {
@@ -842,8 +832,12 @@ export async function runCodexAppServerAttempt(
}),
directToolNames: resolveCodexDynamicToolDirectNames(params),
hookContext: {
...toolHookRunContext,
agentId: sessionAgentId,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
@@ -1450,7 +1444,6 @@ export async function runCodexAppServerAttempt(
config: params.config,
runId: params.runId,
channelId: hookChannelId,
toolHookContext: toolHookRunContext,
attemptTimeoutMs: params.timeoutMs,
startupTimeoutMs,
turnStartTimeoutMs: params.timeoutMs,
@@ -2157,7 +2150,6 @@ export async function runCodexAppServerAttempt(
method: request.method,
params: request.params,
paramsForRun: params,
toolHookContext: toolHookRunContext,
threadId: thread.threadId,
turnId,
nativeHookRelay,
@@ -2769,7 +2761,6 @@ export async function runCodexAppServerAttempt(
nativePostToolUseRelayEnabled:
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
toolHookContext: toolHookRunContext,
trajectoryRecorder,
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
},
@@ -3439,7 +3430,6 @@ function handleApprovalRequest(params: {
method: string;
params: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: NativeHookRelayRegistrationHandle;
@@ -3453,7 +3443,6 @@ function handleApprovalRequest(params: {
method: params.method,
requestParams: params.params,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
threadId: params.threadId,
turnId: params.turnId,
nativeHookRelay: params.nativeHookRelay,

View File

@@ -861,12 +861,9 @@ describe("runCodexAppServerSideQuestion", () => {
).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:runtime-policy",
sessionKey: "agent:main:session-1",
runId: "run-side-1",
channelId: "voice-room",
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
});
return threadResult("side-thread");
@@ -892,7 +889,6 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
@@ -975,7 +971,6 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
opts: { runId: "run-side-approval" },
@@ -993,7 +988,6 @@ describe("runCodexAppServerSideQuestion", () => {
threadId?: string;
turnId?: string;
paramsForRun?: { messageChannel?: string; messageProvider?: string };
toolHookContext?: { sessionKey?: string };
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
}
| undefined;
@@ -1013,9 +1007,6 @@ describe("runCodexAppServerSideQuestion", () => {
messageChannel: "discord",
messageProvider: "discord-voice",
},
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId: relayIdDuringFork,
@@ -1491,14 +1482,6 @@ describe("runCodexAppServerSideQuestion", () => {
});
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
const beforeToolCall = vi.fn();
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
let toolResponse: unknown;
client.request.mockImplementation(async (method: string) => {
@@ -1544,13 +1527,6 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
expect(toolSignal).toBeInstanceOf(AbortSignal);
expect(toolOptions).toBeUndefined();
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(mockCall(beforeToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey: "session-1" }),
);
expect(toolResponse).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "tool output" }],
@@ -1634,29 +1610,14 @@ describe("runCodexAppServerSideQuestion", () => {
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
});
it("preserves requester identity while normalizing side-thread hook channels", async () => {
const afterToolCall = vi.fn();
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
const beforeToolCall = vi.fn((...args: unknown[]) => {
const context = args[1] as Record<string, unknown>;
expect(context).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
});
const context = args[1] as { channelId?: string };
expect(context.channelId).toBe("voice-room");
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
@@ -1696,48 +1657,17 @@ describe("runCodexAppServerSideQuestion", () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:conversation",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
),
).resolves.toEqual({ text: "Tool answer." });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
});
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:runtime-policy",
runSessionKey: "agent:main:conversation",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
hookChannelId: "voice-room",
chatId: "native-voice-chat",
hookChannelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
expect.objectContaining({ hookChannelId: "voice-room" }),
);
expect(
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
).toBeUndefined();
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,5 +1,6 @@
// Codex plugin module implements side question behavior.
import {
buildAgentHookContextChannelFields,
embeddedAgentLog,
formatErrorMessage,
resolveAgentDir,
@@ -15,7 +16,6 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
@@ -89,7 +89,6 @@ import {
resolveCodexBindingModelProviderFallback,
resolveReasoningEffort,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import {
resolveCodexWebSearchPlan,
@@ -207,21 +206,9 @@ export async function runCodexAppServerSideQuestion(
});
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
const toolHookSessionKey =
sideRunParams.sandboxSessionKey?.trim() ||
sideRunParams.sessionKey?.trim() ||
sideRunParams.sessionId ||
sessionAgentId;
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: sideRunParams,
agentId: sessionAgentId,
sessionId: sideRunParams.sessionId,
sessionKey: toolHookSessionKey,
runId: sideRunParams.runId,
});
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: sideRunParams.config,
sessionKey: toolHookSessionKey,
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
sessionId: sideRunParams.sessionId,
surface: "/btw side-question mode",
});
@@ -300,7 +287,6 @@ export async function runCodexAppServerSideQuestion(
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
signal: runAbortController.signal,
toolHookContext: toolHookRunContext,
});
removeRequestHandler = client.addRequestHandler(async (request) => {
if (request.method === "account/chatgptAuthTokens/refresh") {
@@ -333,20 +319,19 @@ export async function runCodexAppServerSideQuestion(
method: request.method,
requestParams: request.params,
paramsForRun: sideRunParams,
toolHookContext: toolHookRunContext,
threadId: childThreadId,
turnId,
nativeHookRelay,
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
}
if (request.method !== "item/tool/call") {
return undefined;
@@ -403,11 +388,15 @@ export async function runCodexAppServerSideQuestion(
events: nativeHookRelayEvents,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: toolHookRunContext.sessionKey,
sessionKey: params.sessionKey,
config: params.cfg,
runId: sideRunParams.runId,
channelId: toolHookRunContext.channelId,
toolHookContext: toolHookRunContext,
channelId: buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
}).channelId,
requestTimeoutMs: appServer.requestTimeoutMs,
completionTimeoutMs: Math.max(
appServer.turnCompletionIdleTimeoutMs,
@@ -430,12 +419,12 @@ export async function runCodexAppServerSideQuestion(
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
});
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const forkResponse = assertCodexThreadForkResponse(
await forkCodexSideThread(
client,
@@ -447,7 +436,7 @@ export async function runCodexAppServerSideQuestion(
cwd,
approvalPolicy,
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(serviceTier ? { serviceTier } : {}),
config: threadConfig,
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
@@ -553,7 +542,6 @@ function registerCodexSideNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
requestTimeoutMs: number;
completionTimeoutMs: number;
signal: AbortSignal;
@@ -569,7 +557,6 @@ function registerCodexSideNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexSideNativeHookRelayTtlMs({
explicitTtlMs: params.options.ttlMs,
@@ -609,7 +596,6 @@ function buildSideRunAttemptParams(
provider: params.provider,
modelId: params.model,
model: params.runtimeModel ?? ({ id: params.model, provider: params.provider } as never),
trigger: "user" as const,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
@@ -630,8 +616,6 @@ function buildSideRunAttemptParams(
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
...(params.chatId ? { chatId: params.chatId } : {}),
...(params.channelContext ? { channelContext: params.channelContext } : {}),
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
workspaceDir: options.cwd,
@@ -663,7 +647,6 @@ async function createCodexSideToolBridge(input: {
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
signal: AbortSignal;
toolHookContext: ToolHookRunContext;
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
const runtimeModel =
input.params.runtimeModel ??
@@ -674,7 +657,10 @@ async function createCodexSideToolBridge(input: {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
.createOpenClawCodingTools;
const sandboxSessionKey =
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
input.params.sandboxSessionKey?.trim() ||
input.params.sessionKey?.trim() ||
input.params.sessionId ||
input.sessionAgentId;
const sandbox = await resolveSandboxContext({
config: input.params.cfg,
sessionKey: sandboxSessionKey,
@@ -710,9 +696,6 @@ async function createCodexSideToolBridge(input: {
workspaceDir: input.cwd,
}),
suppressManagedWebSearch: false,
trigger: input.toolHookContext.trigger,
jobId: input.toolHookContext.jobId,
messageChannel: input.params.messageChannel,
...(input.params.messageProvider || input.params.messageChannel
? {
messageProvider: messageToolProvider,
@@ -732,8 +715,6 @@ async function createCodexSideToolBridge(input: {
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
chatId: input.toolHookContext.chatId,
hookChannelContext: input.toolHookContext.channelContext,
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
...(input.params.senderUsername !== undefined
? { senderUsername: input.params.senderUsername }
@@ -743,7 +724,12 @@ async function createCodexSideToolBridge(input: {
? { senderIsOwner: input.params.senderIsOwner }
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: input.toolHookContext.channelId,
hookChannelId: buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
}).channelId,
sandbox,
emitBeforeToolCallDiagnostics: false,
modelHasVision: runtimeModel.input?.includes("image") ?? false,
@@ -771,15 +757,25 @@ async function createCodexSideToolBridge(input: {
})
: requestedWebSearchPlan;
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
const hookChannelFields = buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
});
return {
toolBridge: createCodexDynamicToolBridge({
tools: exposedTools,
signal: input.signal,
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
...input.toolHookContext,
agentId: input.sessionAgentId,
config: input.params.cfg,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
}),
webSearchPlan,

View File

@@ -1,41 +0,0 @@
/** Builds one canonical requester-origin snapshot for Codex tool hook paths. */
import {
buildAgentHookContextOriginFields,
type EmbeddedRunAttemptParams,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
/** Build the plain run metadata shared by Codex before/after tool hook owners. */
export function buildCodexToolHookRunContext(params: {
attempt: EmbeddedRunAttemptParams;
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
}): ToolHookRunContext {
const attempt = params.attempt;
const agentId = params.agentId ?? attempt.agentId;
const sessionKey = params.sessionKey ?? attempt.sessionKey;
const sessionId = params.sessionId ?? attempt.sessionId;
const runId = params.runId ?? attempt.runId;
return {
...(agentId ? { agentId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(sessionId ? { sessionId } : {}),
...(runId ? { runId } : {}),
...(attempt.jobId ? { jobId: attempt.jobId } : {}),
...(attempt.trigger ? { trigger: attempt.trigger } : {}),
...buildAgentHookContextOriginFields({
sessionKey,
messageChannel: attempt.messageChannel,
messageProvider: attempt.messageProvider ?? attempt.messageChannel,
currentChannelId: params.channelId ?? attempt.currentChannelId,
messageTo: attempt.currentMessagingTarget ?? attempt.messageTo,
trigger: attempt.trigger,
senderId: attempt.senderId,
chatId: attempt.chatId,
channelContext: attempt.channelContext,
}),
};
}

View File

@@ -11,11 +11,7 @@ import type {
PluginHookInboundClaimEvent,
} from "openclaw/plugin-sdk/plugin-entry";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
} from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { resolveCodexAppServerForModelProvider } from "./app-server/app-server-policy.js";
import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
@@ -881,10 +877,11 @@ function readSessionExecOverrides(params: {
return undefined;
}
const storePath = resolveStorePath(params.config.session?.store, { agentId: params.agentId });
const entry = resolveSessionStoreEntry({
store: loadSessionStore(storePath, { skipCache: true }),
const entry = getSessionEntry({
storePath,
sessionKey,
}).existing;
readConsistency: "latest",
});
if (!entry?.execSecurity && !entry?.execAsk) {
return undefined;
}

View File

@@ -340,22 +340,7 @@ describe("runCopilotAttempt", () => {
return { sdkTools: [], sourceTools: [] };
});
const params = makeParams();
Object.assign(params, {
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "C123",
chatId: "C123",
senderId: "U123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
});
await runCopilotAttempt(params, {
await runCopilotAttempt(makeParams(), {
createToolBridge,
pool: makeFakePool(sdk),
});
@@ -402,21 +387,7 @@ describe("runCopilotAttempt", () => {
toolCallId: "tool-call-1",
toolName: "read",
}),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
);
});
@@ -1316,32 +1287,6 @@ describe("runCopilotAttempt", () => {
).toBe(sdkTools);
});
it("passes the session-resolved agent id to the tool bridge", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(
makeParams({
agentId: undefined,
sessionKey: "agent:beta:main",
config: {
agents: {
list: [{ id: "main" }, { id: "beta" }],
},
} as never,
}),
{ createToolBridge, pool },
);
expect(createToolBridge).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "beta",
sessionKey: "agent:beta:main",
}),
);
});
it("F6: sessionRef is populated after createSession so the tool bridge's onYield can abort the live SDK session", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);

View File

@@ -9,7 +9,6 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
detectAndLoadAgentHarnessPromptImages,
getModelProviderRequestTransport,
resolveAgentHarnessBeforePromptBuildResult,
@@ -410,25 +409,6 @@ export async function runCopilotAttempt(
...hookContextWindowFields,
...buildAgentHookContextChannelFields(input),
};
const toolHookRunContext = {
runId: input.runId,
jobId: input.jobId,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: input.sessionId,
trigger: input.trigger,
...buildAgentHookContextOriginFields({
sessionKey: sandboxSessionKey,
messageChannel: input.messageChannel,
messageProvider: input.messageProvider ?? input.messageChannel,
currentChannelId: input.currentChannelId,
messageTo: input.currentMessagingTarget ?? input.messageTo,
trigger: input.trigger,
senderId: input.senderId,
chatId: input.chatId,
channelContext: input.channelContext,
}),
};
const finishAttempt = (result: AgentHarnessAttemptResult) =>
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
@@ -646,7 +626,7 @@ export async function runCopilotAttempt(
allowModelTools: poolAcquire.provider.mode === "byok",
modelProvider: modelRef.provider,
modelId: modelRef.id,
agentId: sessionAgentId,
agentId: readString(params.agentId) ?? "copilot",
sessionId: readString(input.sessionId) ?? "copilot-session",
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentDir: readString(input.agentDir),
@@ -672,7 +652,11 @@ export async function runCopilotAttempt(
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
...toolHookRunContext,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -1,17 +1,11 @@
// Copilot tests cover tool bridge plugin behavior.
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createCopilotToolBridge,
convertOpenClawToolToSdkTool,
supportsModelTools,
testing,
} from "./tool-bridge.js";
type FakeTool = AnyAgentTool & {
@@ -83,7 +77,6 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
}
afterEach(() => {
resetGlobalHookRunner();
vi.restoreAllMocks();
});
@@ -316,79 +309,6 @@ describe("createCopilotToolBridge", () => {
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("runs requester-aware policy before code-mode exec controls", async () => {
const beforeToolCall = vi.fn(() => ({
block: true,
blockReason: "blocked before code-mode execution",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const createOpenClawCodingTools = vi.fn(async () => [makeTool({ name: "read" })]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionId: "session-1",
sessionKey: "agent:main:main",
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "slack:C123",
senderId: "U123",
channelContext: { sender: { id: "U123", displayName: "Ada" } },
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
const exec = result.sdkTools.find((tool) => tool.name === "exec");
if (!exec) {
throw new Error("missing code-mode exec control");
}
await runSdkTool(
exec,
{ code: "return 1;" },
makeInvocation({ toolCallId: "code-call-1", toolName: "exec" }),
);
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(beforeToolCall).toHaveBeenCalledWith(
{
toolName: "exec",
params: { code: "return 1;", command: "return 1;" },
toolKind: "code_mode_exec",
toolInputKind: "javascript",
runId: "run-code-mode",
toolCallId: "code-call-1",
},
{
toolName: "exec",
toolKind: "code_mode_exec",
toolInputKind: "javascript",
agentId: "agent-1",
sessionKey: "agent:main:main",
sessionId: "session-1",
runId: "run-code-mode",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
senderId: "U123",
toolCallId: "code-call-1",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
},
},
);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
@@ -523,13 +443,7 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
channelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageProvider: "slack",
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -563,13 +477,7 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
hookChannelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageProvider: "slack",
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -577,7 +485,6 @@ describe("createCopilotToolBridge", () => {
forceMessageTool: true,
enableHeartbeatTool: true,
});
expect(opts.channelContext).toBeUndefined();
});
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
@@ -595,63 +502,6 @@ describe("createCopilotToolBridge", () => {
expect(getOpts().messageProvider).toBe("telegram");
});
it("uses messageTo when currentMessagingTarget is absent in tool hook routing", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
messageChannel: "slack",
messageProvider: "slack",
messageTo: "user:U-only",
trigger: "user",
});
expect(context).toMatchObject({
channel: "slack",
messageProvider: "slack",
channelId: "U-only",
turnSourceChannel: "slack",
turnSourceTo: "user:U-only",
});
expect(context.chatId).toBeUndefined();
expect(context.channelContext).toBeUndefined();
});
it("resolves per-agent loop detection overrides for generated code-mode controls", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
config: {
tools: {
loopDetection: {
enabled: true,
warningThreshold: 7,
detectors: { genericRepeat: true },
postCompactionGuard: { windowSize: 4 },
},
},
agents: {
list: [
{
id: "agent-1",
tools: {
loopDetection: {
enabled: false,
detectors: { pingPong: false },
postCompactionGuard: { windowSize: 2 },
},
},
},
],
},
},
});
expect(context.loopDetection).toEqual({
enabled: false,
warningThreshold: 7,
detectors: { genericRepeat: true, pingPong: false },
postCompactionGuard: { windowSize: 2 },
});
});
it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => {
const { createOpenClawCodingTools, getOpts } = captureCall();
const authProfileStore = { kind: "fake-store" } as never;

View File

@@ -7,19 +7,15 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
applyEmbeddedAttemptToolsAllow,
buildAgentHookContextOriginFields,
buildEmbeddedAttemptToolRunContext,
extractToolErrorMessage,
getPluginToolMeta,
isSubagentSessionKey,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
resolveAttemptSpawnWorkspaceDir,
resolveEmbeddedAttemptToolConstructionPlan,
resolveModelAuthMode,
resolveToolLoopDetectionConfig,
sanitizeToolResult,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
@@ -148,7 +144,6 @@ export interface CopilotToolBridge {
export const SUPPORTED_TOOL_PROVIDERS: ReadonlySet<string> = new Set(["github-copilot"]);
const BASE_COPILOT_CODING_TOOL_NAMES = new Set(["edit", "read", "write"]);
const SHELL_COPILOT_CODING_TOOL_NAMES = new Set(["apply_patch", "exec", "process"]);
const CODE_MODE_CONTROL_TOOL_NAMES = new Set(["exec", "wait"]);
export function supportsModelTools(modelProvider: string): boolean {
return SUPPORTED_TOOL_PROVIDERS.has(modelProvider);
@@ -215,7 +210,6 @@ export async function createCopilotToolBridge(
},
toolSurfaceRuntime,
);
const toolHookContext = buildCopilotToolHookContext(toolOptions);
let sourceTools: unknown;
try {
@@ -237,18 +231,9 @@ export async function createCopilotToolBridge(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools, {
hookContext: toolHookContext,
});
const hookedCompactedTools = compactedTools.tools.map((tool) =>
!toolSurfaceRuntime.codeModeControlsEnabled ||
!CODE_MODE_CONTROL_TOOL_NAMES.has(tool.name) ||
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, toolHookContext),
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
const plannedTools = filterCopilotToolsForConstructionPlan(
hookedCompactedTools,
compactedTools.tools,
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
@@ -279,51 +264,6 @@ export async function createCopilotToolBridge(
};
}
function buildCopilotToolHookContext(toolOptions: OpenClawCodingToolsOptions) {
const turnSourceChannel = toolOptions.messageChannel ?? toolOptions.messageProvider;
const messageTo = toolOptions.currentMessagingTarget ?? toolOptions.messageTo;
const turnSourceTo = messageTo ?? toolOptions.currentChannelId;
return {
agentId: toolOptions.agentId,
config: toolOptions.config,
cwd: toolOptions.cwd,
workspaceDir: toolOptions.workspaceDir,
sessionKey: toolOptions.sessionKey,
sessionId: toolOptions.sessionId,
runId: toolOptions.runId,
jobId: toolOptions.jobId,
trace: toolOptions.trace,
trigger: toolOptions.trigger,
...buildAgentHookContextOriginFields({
sessionKey: toolOptions.sessionKey,
messageChannel: toolOptions.messageChannel,
messageProvider: toolOptions.toolPolicyMessageProvider ?? toolOptions.messageProvider,
currentChannelId: toolOptions.hookChannelId ?? toolOptions.currentChannelId,
messageTo,
trigger: toolOptions.trigger,
senderId: toolOptions.senderId,
chatId: toolOptions.chatId,
channelContext: toolOptions.hookChannelContext ?? toolOptions.channelContext,
}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(toolOptions.agentAccountId ? { turnSourceAccountId: toolOptions.agentAccountId } : {}),
...(toolOptions.currentThreadTs ? { turnSourceThreadId: toolOptions.currentThreadTs } : {}),
loopDetection: resolveToolLoopDetectionConfig({
cfg: toolOptions.config,
agentId: toolOptions.agentId,
}),
onToolOutcome: toolOptions.onToolOutcome,
allocateToolOutcomeOrdinal: toolOptions.allocateToolOutcomeOrdinal,
};
}
/** Test-only access to requester-context construction. */
export const testing = {
buildCopilotToolHookContext: (toolOptions: unknown): Record<string, unknown> =>
buildCopilotToolHookContext(toolOptions as OpenClawCodingToolsOptions),
};
/**
* Builds the full `createOpenClawCodingTools` options bag mirroring the
* PI in-tree call at `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`.
@@ -410,9 +350,7 @@ function buildOpenClawCodingToolsOptions(
...a.execOverrides,
elevated: a.bashElevated,
},
messageChannel: a.messageChannel,
messageProvider: a.messageProvider ?? a.messageChannel,
toolPolicyMessageProvider: a.messageProvider ?? a.messageChannel,
agentAccountId: a.agentAccountId,
messageTo: a.messageTo,
messageThreadId: a.messageThreadId,
@@ -422,7 +360,6 @@ function buildOpenClawCodingToolsOptions(
memberRoleIds: a.memberRoleIds,
spawnedBy: a.spawnedBy,
senderId: a.senderId,
hookChannelContext: a.channelContext,
senderName: a.senderName,
senderUsername: a.senderUsername,
senderE164: a.senderE164,
@@ -458,7 +395,6 @@ function buildOpenClawCodingToolsOptions(
workspaceDir,
}),
currentChannelId: a.currentChannelId,
chatId: a.chatId,
currentMessagingTarget: a.currentMessagingTarget,
currentThreadTs: a.currentThreadTs,
currentMessageId: a.currentMessageId,

View File

@@ -175,6 +175,7 @@ type DispatchInboundParams = {
}) => Promise<void> | void;
onReplyStart?: () => Promise<void> | void;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
typingKeepalive?: boolean;
disableBlockStreaming?: boolean;
suppressDefaultToolProgressMessages?: boolean;
queuedDeliveryCorrelations?: Array<{ begin: () => () => void }>;
@@ -944,6 +945,7 @@ describe("processDiscordMessage ack reactions", () => {
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onIdle).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onCleanup).toHaveBeenCalledTimes(1);
expect(getLastDispatchReplyOptions()?.typingKeepalive).toBe(false);
expect(typingMocks.sendTyping).not.toHaveBeenCalled();
});
@@ -984,6 +986,33 @@ describe("processDiscordMessage ack reactions", () => {
}
});
it("keeps one typing refresh loop for default message-tool replies", async () => {
vi.useFakeTimers();
try {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReplyStart?.();
await vi.advanceTimersByTimeAsync(3_500);
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({
shouldRequireMention: false,
effectiveWasMentioned: false,
cfg: {
messages: { groupChat: { visibleReplies: "message_tool" } },
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,
});
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.typingKeepalive).toBe(false);
expect(typingMocks.sendTyping).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
@@ -1532,6 +1561,7 @@ describe("processDiscordMessage session routing", () => {
expectRecordFields(requireRecord(getLastDispatchReplyOptions(), "dispatch reply options"), {
sourceReplyDeliveryMode: "message_tool_only",
typingKeepalive: false,
disableBlockStreaming: true,
});
expect(createDiscordDraftStream).not.toHaveBeenCalled();

View File

@@ -251,6 +251,14 @@ async function processDiscordMessageInner(
},
});
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const configuredTypingMode = cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode;
const configuredTypingInterval =
cfg.agents?.defaults?.typingIntervalSeconds ?? cfg.session?.typingIntervalSeconds;
const shouldDisableCoreTypingKeepalive =
Boolean(replyTypingFeedback) ||
(sourceRepliesAreToolOnly &&
configuredTypingMode === undefined &&
configuredTypingInterval === undefined);
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "discord",
accountId,
@@ -460,6 +468,7 @@ async function processDiscordMessageInner(
channelId: typingChannelId,
rest: feedbackRest,
log: logVerbose,
keepaliveIntervalMs: shouldDisableCoreTypingKeepalive ? undefined : 0,
});
if (replyTypingFeedback) {
// A carried prestart only covers queue wait time; dispatch needs a fresh
@@ -955,6 +964,7 @@ async function processDiscordMessageInner(
abortSignal,
skillFilter: channelConfig?.skills,
sourceReplyDeliveryMode,
typingKeepalive: shouldDisableCoreTypingKeepalive ? false : undefined,
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
suppressTyping: isRoomEvent ? true : undefined,
allowProgressCallbacksWhenSourceDeliverySuppressed:

View File

@@ -222,6 +222,34 @@ describe("createDiscordMessageHandler queue behavior", () => {
);
});
it("keeps the configured typing cadence for prestarted feedback", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockImplementation(async () =>
createAcceptedDmPreflightContext({
cfg: {
...createPreflightContext().cfg,
agents: { defaults: { typingIntervalSeconds: 7 } },
session: { typingIntervalSeconds: 5 },
},
}),
);
processDiscordMessageMock.mockResolvedValue(undefined);
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await handler(createMessageData("m-typing-cadence", "dm-1") as never, {} as never);
await flushQueueWork();
expect(createReplyTypingFeedback).toHaveBeenCalledWith(
expect.objectContaining({ keepaliveIntervalMs: 7_000 }),
);
});
it("keeps accepted DM dispatch running when accepted typing feedback fails", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();

View File

@@ -3,6 +3,7 @@ import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import type { Client } from "../internal/discord.js";
@@ -102,6 +103,9 @@ function startAcceptedTypingFeedback(params: {
accountId: ctx.accountId,
channelId: ctx.messageChannelId,
log: logVerbose,
keepaliveIntervalMs: finiteSecondsToTimerSafeMilliseconds(
ctx.cfg.agents?.defaults?.typingIntervalSeconds ?? ctx.cfg.session?.typingIntervalSeconds,
),
});
const cleanup = replyTypingFeedback.onCleanup;
replyTypingFeedback.onCleanup = () => {

View File

@@ -8,7 +8,7 @@ import {
} from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -202,11 +202,10 @@ export async function resolveDiscordNativeChoiceContext(params: {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: route.agentId,
});
const sessionStore = loadSessionStore(storePath);
const sessionEntry = sessionStore[route.sessionKey];
const sessionEntry = getSessionEntry({ storePath, sessionKey: route.sessionKey });
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
sessionKey: route.sessionKey,
defaultProvider: fallback.provider,
});
@@ -238,11 +237,15 @@ export function resolveDiscordModelPickerCurrentModel(params: {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionStore = loadSessionStore(storePath, { skipCache: true });
const sessionEntry = sessionStore[params.route.sessionKey];
const sessionEntry = getSessionEntry({
storePath,
sessionKey: params.route.sessionKey,
readConsistency: "latest",
});
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
loadSessionEntry: (sessionKey) =>
getSessionEntry({ storePath, sessionKey, readConsistency: "latest" }),
sessionKey: params.route.sessionKey,
defaultProvider: params.data.resolvedDefault.provider,
});
@@ -267,9 +270,12 @@ export function resolveDiscordModelPickerCurrentRuntime(params: {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionStore = loadSessionStore(storePath, { skipCache: true });
const sessionRuntime = normalizeOptionalString(
sessionStore[params.route.sessionKey]?.agentRuntimeOverride,
getSessionEntry({
storePath,
sessionKey: params.route.sessionKey,
readConsistency: "latest",
})?.agentRuntimeOverride,
);
if (sessionRuntime) {
return sessionRuntime;

View File

@@ -24,6 +24,7 @@ export function createDiscordReplyTypingFeedback(params: {
rest?: RequestClient;
log: (message: string) => void;
maxDurationMs?: number;
keepaliveIntervalMs?: number;
}): DiscordReplyTypingFeedback {
let channelId = params.channelId;
const rest =
@@ -44,6 +45,7 @@ export function createDiscordReplyTypingFeedback(params: {
error: err,
});
},
keepaliveIntervalMs: params.keepaliveIntervalMs,
maxDurationMs: params.maxDurationMs ?? DISCORD_REPLY_TYPING_MAX_DURATION_MS,
});
const updateChannelId = (nextChannelId: string) => {

View File

@@ -1,5 +1,6 @@
// Duckduckgo plugin module implements ddg client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_SEARCH_COUNT,
@@ -36,21 +37,49 @@ type DuckDuckGoResult = {
};
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "-")
.replace(/&mdash;/g, "--")
.replace(/&hellip;/g, "...")
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
return text.replace(
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
(entity) => {
const normalized = entity.toLowerCase();
if (normalized === "&lt;") {
return "<";
}
if (normalized === "&gt;") {
return ">";
}
if (normalized === "&quot;") {
return '"';
}
if (normalized === "&apos;" || normalized === "&#39;" || normalized === "&#x27;") {
return "'";
}
if (normalized === "&#x2f;") {
return "/";
}
if (normalized === "&nbsp;") {
return " ";
}
if (normalized === "&ndash;") {
return "-";
}
if (normalized === "&mdash;") {
return "--";
}
if (normalized === "&hellip;") {
return "...";
}
if (normalized === "&amp;") {
return "&";
}
if (normalized.startsWith("&#x")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
}
if (normalized.startsWith("&#")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
}
return entity;
},
);
}
function stripHtml(html: string): string {
@@ -85,6 +114,10 @@ function isBotChallenge(html: string): boolean {
return /g-recaptcha|are you a human|id="challenge-form"|name="challenge"/i.test(html);
}
async function readDuckDuckGoHtmlResponse(response: Response): Promise<string> {
return await readProviderTextResponse(response, "DuckDuckGo search");
}
function parseDuckDuckGoHtml(html: string): DuckDuckGoResult[] {
const results: DuckDuckGoResult[] = [];
const resultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi;
@@ -174,7 +207,7 @@ export async function runDuckDuckGoSearch(params: {
);
}
const html = await response.text();
const html = await readDuckDuckGoHtmlResponse(response);
if (isBotChallenge(html)) {
throw new Error("DuckDuckGo returned a bot-detection challenge.");
}
@@ -210,5 +243,6 @@ export const testing = {
decodeHtmlEntities,
isBotChallenge,
parseDuckDuckGoHtml,
readDuckDuckGoHtmlResponse,
};
export { testing as __testing };

View File

@@ -1,5 +1,6 @@
// Duckduckgo tests cover ddg search provider plugin behavior.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createDuckDuckGoWebSearchProvider as createDuckDuckGoWebSearchContractProvider } from "../web-search-contract-api.js";
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
@@ -104,6 +105,24 @@ describe("duckduckgo web search provider", () => {
expect(runDuckDuckGoSearch).not.toHaveBeenCalled();
});
it("bounds successful DuckDuckGo HTML bodies without using response.text()", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "Content-Type": "text/html" },
});
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
await expect(ddgClientTesting.readDuckDuckGoHtmlResponse(streamed.response)).rejects.toThrow(
"DuckDuckGo search: text response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reads region from plugin config and normalizes empty values away", () => {
expect(
resolveDdgRegion({
@@ -186,6 +205,17 @@ describe("duckduckgo web search provider", () => {
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// A result whose text literally shows "&lt;" arrives double-encoded as
// "&amp;lt;". Decoding &amp; first would re-decode it into "<", corrupting
// the snippet; &amp; must be decoded last.
expect(ddgClientTesting.decodeHtmlEntities("How to escape &amp;lt; in HTML")).toBe(
"How to escape &lt; in HTML",
);
expect(ddgClientTesting.decodeHtmlEntities("a&amp;#39;b")).toBe("a&#39;b");
expect(ddgClientTesting.decodeHtmlEntities("a&#x26;amp;b")).toBe("a&amp;b");
});
it("parses results when href appears before class", () => {
const html = `
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">

View File

@@ -20,6 +20,7 @@ import {
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -30,6 +31,10 @@ const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "i
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
// Exa search responses are untrusted external bodies. Cap the success JSON the
// same way other bundled providers do (16 MiB) so a misbehaving or hostile
// endpoint cannot stream an unbounded body into memory before we parse it.
const EXA_SEARCH_JSON_MAX_BYTES = 16 * 1024 * 1024;
type ExaConfig = {
apiKey?: string;
@@ -70,9 +75,17 @@ type ExaSearchResponse = {
results?: unknown;
};
async function readExaSearchResults(response: Response): Promise<ExaSearchResult[]> {
async function readExaSearchResults(
response: Response,
opts?: { maxBytes?: number },
): Promise<ExaSearchResult[]> {
const maxBytes = opts?.maxBytes ?? EXA_SEARCH_JSON_MAX_BYTES;
const bytes = await readResponseWithLimit(response, maxBytes, {
onOverflow: ({ maxBytes: maxBytesLocal }) =>
new Error(`Exa API response exceeds ${maxBytesLocal} bytes`),
});
try {
return normalizeExaResults(await response.json());
return normalizeExaResults(JSON.parse(new TextDecoder().decode(bytes)));
} catch (cause) {
throw new Error("Exa API returned malformed JSON", { cause });
}

View File

@@ -26,6 +26,33 @@ function cancelTrackedResponse(
};
}
function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
} {
// Streaming fixture proves an oversized success body stops being read before
// the whole payload is buffered into memory.
let reads = 0;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
controller.close();
return;
}
reads += 1;
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
getReadCount: () => reads,
};
}
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
@@ -265,6 +292,27 @@ describe("exa web search provider", () => {
);
});
it("parses well-formed Exa search JSON under the byte cap", async () => {
const response = new Response(
JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
await expect(testing.readExaSearchResults(response)).resolves.toEqual([
{ url: "https://example.com", title: "Example" },
]);
});
it("caps oversized Exa search JSON instead of buffering the whole body", async () => {
const streamed = streamingJsonResponse({ chunkCount: 64, chunkSize: 1024 });
await expect(
testing.readExaSearchResults(streamed.response, { maxBytes: 4096 }),
).rejects.toThrow(/Exa API response exceeds 4096 bytes/);
expect(streamed.getReadCount()).toBeLessThan(64);
});
it("bounds Exa API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
status: 503,

View File

@@ -43,10 +43,7 @@ export {
filterSupplementalContextItems,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/context-visibility-runtime";
export {
loadSessionStore,
resolveSessionStoreEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
export { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";

View File

@@ -10,4 +10,4 @@ export {
filterSupplementalContextItems,
normalizeAgentId,
} from "../runtime-api.js";
export { loadSessionStore, resolveSessionStoreEntry } from "../runtime-api.js";
export { getSessionEntry } from "../runtime-api.js";

View File

@@ -3,8 +3,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "./bot-runtime-api.js";
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
const { loadSessionStoreMock } = vi.hoisted(() => ({
loadSessionStoreMock: vi.fn(),
const { getSessionEntryMock } = vi.hoisted(() => ({
getSessionEntryMock: vi.fn(),
}));
vi.mock("./bot-runtime-api.js", async () => {
@@ -12,7 +12,7 @@ vi.mock("./bot-runtime-api.js", async () => {
await vi.importActual<typeof import("./bot-runtime-api.js")>("./bot-runtime-api.js");
return {
...actual,
loadSessionStore: loadSessionStoreMock,
getSessionEntry: getSessionEntryMock,
};
});
@@ -29,9 +29,12 @@ describe("resolveFeishuReasoningPreviewEnabled", () => {
});
it("enables previews only for stream reasoning sessions", () => {
loadSessionStoreMock.mockReturnValue({
"agent:main:feishu:dm:ou_sender_1": { reasoningLevel: "stream" },
"agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "on" },
getSessionEntryMock.mockImplementation(({ sessionKey }) => {
const entries = {
"agent:main:feishu:dm:ou_sender_1": { reasoningLevel: "stream" },
"agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "on" },
};
return entries[sessionKey as keyof typeof entries];
});
expect(
@@ -50,10 +53,15 @@ describe("resolveFeishuReasoningPreviewEnabled", () => {
sessionKey: "agent:main:feishu:dm:ou_sender_2",
}),
).toBe(false);
expect(getSessionEntryMock).toHaveBeenCalledWith({
storePath: "/tmp/feishu-sessions.json",
sessionKey: "agent:main:feishu:dm:ou_sender_1",
readConsistency: "latest",
});
});
it("returns false for missing sessions or load failures", () => {
loadSessionStoreMock.mockImplementationOnce(() => {
getSessionEntryMock.mockImplementationOnce(() => {
throw new Error("disk unavailable");
});
@@ -75,9 +83,12 @@ describe("resolveFeishuReasoningPreviewEnabled", () => {
});
it("falls back to configured stream defaults", () => {
loadSessionStoreMock.mockReturnValue({
"agent:main:feishu:dm:ou_sender_1": {},
"agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "off" },
getSessionEntryMock.mockImplementation(({ sessionKey }) => {
const entries = {
"agent:main:feishu:dm:ou_sender_1": {},
"agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "off" },
};
return entries[sessionKey as keyof typeof entries];
});
const cfg: ClawdbotConfig = {

View File

@@ -1,6 +1,6 @@
// Feishu plugin module implements reasoning preview behavior.
import { resolveFeishuConfigReasoningDefault } from "./agent-config.js";
import { loadSessionStore, resolveSessionStoreEntry } from "./bot-runtime-api.js";
import { getSessionEntry } from "./bot-runtime-api.js";
import type { ClawdbotConfig } from "./bot-runtime-api.js";
export function resolveFeishuReasoningPreviewEnabled(params: {
@@ -16,9 +16,11 @@ export function resolveFeishuReasoningPreviewEnabled(params: {
}
try {
const store = loadSessionStore(params.storePath, { skipCache: true });
const level = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing
?.reasoningLevel;
const level = getSessionEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
readConsistency: "latest",
})?.reasoningLevel;
if (level === "on" || level === "stream" || level === "off") {
return level === "stream";
}

View File

@@ -1,5 +1,6 @@
// Firecrawl plugin module implements firecrawl client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CACHE_TTL_MINUTES,
markdownToText,
@@ -41,6 +42,7 @@ const SCRAPE_CACHE = new Map<
>();
const DEFAULT_SEARCH_COUNT = 5;
const DEFAULT_SCRAPE_MAX_CHARS = 50_000;
const FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
const ALLOWED_FIRECRAWL_HOSTS = new Set(["api.firecrawl.dev"]);
const FIRECRAWL_SELF_HOSTED_PRIVATE_ERROR =
"Firecrawl custom baseUrl must target a private or internal self-hosted endpoint.";
@@ -65,12 +67,9 @@ type FirecrawlSearchItem = {
async function readFirecrawlJsonResponse(
response: Response,
label: string,
opts?: { maxBytes?: number },
): Promise<Record<string, unknown>> {
try {
return (await response.json()) as Record<string, unknown>;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
return await readProviderJsonResponse<Record<string, unknown>>(response, label, opts);
}
export type FirecrawlSearchParams = {
@@ -220,11 +219,9 @@ async function postFirecrawlJson<T>(
const readJsonPayload = async (): Promise<Record<string, unknown> | null> => {
const candidate = response as Response & { clone?: () => Response };
const jsonResponse = typeof candidate.clone === "function" ? candidate.clone() : response;
if (typeof jsonResponse.json !== "function") {
return null;
}
try {
const payload = await jsonResponse.json();
const body = await readResponseText(jsonResponse, { maxBytes: 64_000 });
const payload = JSON.parse(body.text) as unknown;
return payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
@@ -579,7 +576,10 @@ export async function runFirecrawlScrape(
},
},
async (response) => {
const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed");
const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed", {
// Scrape can legitimately return page bodies before maxChars truncates parsed output.
maxBytes: FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES,
});
if (payloadLocal.success === false) {
const detail =
typeof payloadLocal.error === "string"
@@ -613,6 +613,7 @@ export const testing = {
assertFirecrawlScrapeTargetAllowed,
parseFirecrawlScrapePayload,
postFirecrawlJson,
readFirecrawlJsonResponse,
resolveEndpoint,
validateFirecrawlBaseUrl,
resolveSearchItems,

View File

@@ -2,6 +2,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import {
DEFAULT_FIRECRAWL_BASE_URL,
DEFAULT_FIRECRAWL_MAX_AGE_MS,
@@ -966,6 +967,27 @@ describe("firecrawl tools", () => {
).rejects.toThrow("Firecrawl Search API error: malformed JSON response");
});
it("bounds successful Firecrawl JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
await expect(
firecrawlClientTesting.readFirecrawlJsonResponse(
streamed.response,
"Firecrawl Search API error",
),
).rejects.toThrow("Firecrawl Search API error: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("reports malformed Firecrawl scrape JSON with a stable provider error", async () => {
global.fetch = vi.fn(
async () =>

View File

@@ -256,6 +256,183 @@ describe("iMessage monitor last-route updates", () => {
});
});
it("keeps direct progress options when imsg lacks native typing support", async () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["watch.subscribe", "send", "read"],
});
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(params.replyOptions?.onToolStart).toBeUndefined();
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const client = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
if (method === "typing") {
throw new Error("typing should not start without native typing support");
}
throw new Error(`unexpected imsg method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 13,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "run a long script without native typing",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await Promise.resolve();
await Promise.resolve();
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (!params?.onNotification) {
throw new Error("expected iMessage notification handler");
}
onNotification = params.onNotification;
return client as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
expect(client.request).not.toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true }),
expect.anything(),
);
});
it("starts direct typing before dispatching the inbound turn", async () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["watch.subscribe", "send", "typing"],
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const earlyTypingClient = {
request: vi.fn(async (method: string) => {
if (method === "typing") {
return { ok: true };
}
throw new Error(`unexpected imsg typing-client method ${method}`);
}),
stop: vi.fn(async () => {}),
};
const watchClient = {
request: vi.fn(async (method: string) => {
if (method === "watch.subscribe") {
return { subscription: 1 };
}
if (method === "typing") {
return { ok: true };
}
throw new Error(`unexpected imsg watch-client method ${method}`);
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 12,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "respond after a slow context build",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await vi.waitFor(() => {
expect(earlyTypingClient.request).toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true, to: "+15550001111" }),
expect.any(Object),
);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (params?.onNotification) {
onNotification = params.onNotification;
return watchClient as never;
}
return earlyTypingClient as never;
});
dispatchInboundMessageMock.mockImplementationOnce(async () => {
expect(earlyTypingClient.request).toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true, to: "+15550001111" }),
expect.any(Object),
);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
sendReadReceipts: false,
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(watchClient.request).not.toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: true }),
expect.anything(),
);
await vi.waitFor(() => {
expect(earlyTypingClient.request).toHaveBeenCalledWith(
"typing",
expect.objectContaining({ typing: false, to: "+15550001111" }),
expect.any(Object),
);
});
});
it.each(["never", "message", "thinking"] as const)(
"does not start direct tool typing when typingMode is %s",
async (typingMode) => {
@@ -420,6 +597,87 @@ describe("iMessage monitor last-route updates", () => {
);
});
it("does not wait for read receipts before dispatching the inbound turn", async () => {
setCachedIMessagePrivateApiStatus("imsg", {
available: true,
v2Ready: true,
selectors: {},
rpcMethods: ["watch.subscribe", "read"],
});
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
const readClient = {
request: vi.fn((method: string) => {
if (method === "read") {
return new Promise(() => {});
}
return Promise.reject(new Error(`unexpected imsg read-client method ${method}`));
}),
stop: vi.fn(async () => {}),
};
const watchClient = {
request: vi.fn((method: string) => {
if (method === "watch.subscribe") {
return Promise.resolve({ subscription: 1 });
}
return Promise.reject(new Error(`unexpected imsg watch-client method ${method}`));
}),
waitForClose: vi.fn(async () => {
onNotification?.({
method: "message",
params: {
message: {
id: 11,
chat_id: 123,
sender: "+15550001111",
is_from_me: false,
text: "respond without waiting for read receipt",
is_group: false,
created_at: new Date().toISOString(),
},
},
});
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
}),
stop: vi.fn(async () => {}),
};
createIMessageRpcClientMock.mockImplementation(async (params) => {
if (params?.onNotification) {
onNotification = params.onNotification;
return watchClient as never;
}
return readClient as never;
});
await monitorIMessageProvider({
config: {
channels: {
imessage: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
},
},
messages: { inbound: { debounceMs: 0 } },
session: { mainKey: "main" },
} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
});
expect(readClient.request).toHaveBeenCalledWith(
"read",
expect.objectContaining({ to: "+15550001111" }),
expect.any(Object),
);
expect(watchClient.request).not.toHaveBeenCalledWith(
"read",
expect.anything(),
expect.anything(),
);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
});
it.each([
{
label: "flat true",

View File

@@ -1087,7 +1087,7 @@ function buildIMessageEchoScope(params: {
return scopes;
}
function buildDirectIMessageReplyTarget(params: {
export function buildDirectIMessageReplyTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
sender: string;

View File

@@ -94,6 +94,7 @@ import {
releaseIMessageInboundReplay,
} from "./inbound-dedupe.js";
import {
buildDirectIMessageReplyTarget,
buildIMessageInboundContext,
rememberIMessageSkippedFromMeForSelfChatDedupe,
resolveIMessageReactionContext,
@@ -1039,6 +1040,87 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const storePath = resolveStorePath(cfg.session?.store, {
agentId: decision.route.agentId,
});
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
if (privateApiStatus?.available === true) {
// Surface a single warning per restart when the bridge is up but we
// had to gate off typing/read because the imsg build pre-dates the
// capability list. Otherwise the user sees no typing bubble / no
// "Read" receipt with no visible reason.
if (!supportsTyping || !supportsRead) {
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
}
}
const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg);
const sendPolicy = resolveSendPolicy({
cfg,
entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }),
sessionKey: decision.route.sessionKey,
channel: "imessage",
chatType: decision.isGroup ? "group" : "direct",
});
const shouldUseDirectToolTypingOptions =
!decision.isGroup &&
sendPolicy !== "deny" &&
(configuredTypingMode === undefined || configuredTypingMode === "instant");
const shouldStartDirectTyping = supportsTyping && shouldUseDirectToolTypingOptions;
const earlyDirectTypingTarget = shouldStartDirectTyping
? buildDirectIMessageReplyTarget({
cfg,
accountId: decision.route.accountId,
sender: decision.sender,
})
: undefined;
let stopEarlyDirectTyping: (() => void) | undefined;
if (earlyDirectTypingTarget) {
// Start channel-native feedback before the expensive history/context/model
// path. Use a short-lived client so a slow typing RPC cannot block the
// monitor client's watch stream. Stop is sequenced after start so fast
// command replies cannot leave a late true after typing:false.
const earlyDirectTypingStarted = sendIMessageTyping(earlyDirectTypingTarget, true, {
cfg,
accountId: accountInfo.accountId,
}).then(
() => true,
(err: unknown) => {
logTypingFailure({
log: (msg) => logVerbose(msg),
channel: "imessage",
action: "start",
target: earlyDirectTypingTarget,
error: err,
});
return false;
},
);
let earlyTypingStopQueued = false;
stopEarlyDirectTyping = () => {
if (earlyTypingStopQueued) {
return;
}
earlyTypingStopQueued = true;
void earlyDirectTypingStarted
.then(async (started) => {
if (!started) {
return;
}
await sendIMessageTyping(earlyDirectTypingTarget, false, {
cfg,
accountId: accountInfo.accountId,
});
})
.catch((err: unknown) => {
logTypingFailure({
log: (msg) => logVerbose(msg),
channel: "imessage",
action: "stop",
target: earlyDirectTypingTarget,
error: err,
});
});
};
}
const stagedAttachments = remoteHost
? []
: await stageIMessageAttachments(validAttachments, {
@@ -1107,31 +1189,20 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
}
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
if (privateApiStatus?.available === true) {
// Surface a single warning per restart when the bridge is up but we
// had to gate off typing/read because the imsg build pre-dates the
// capability list. Otherwise the user sees no typing bubble / no
// "Read" receipt with no visible reason.
if (!supportsTyping || !supportsRead) {
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
}
}
const sendReadReceipts = imessageCfg.sendReadReceipts !== false;
const typingTarget = ctxPayload.To;
if (supportsRead && sendReadReceipts && typingTarget) {
try {
await markIMessageChatRead(typingTarget, {
cfg,
accountId: accountInfo.accountId,
client: getActiveClient(),
});
} catch (err) {
// Read receipts are best-effort channel UI. Do not put them on the
// critical path before model dispatch; slow private-API reads otherwise
// make accepted iMessage turns feel stuck before the agent starts. Use
// a short-lived client so a stuck read cannot block monitor-client typing.
void markIMessageChatRead(typingTarget, {
cfg,
accountId: accountInfo.accountId,
}).catch((err: unknown) => {
runtime.error?.(`imessage: mark read failed: ${String(err)}`);
}
});
}
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
@@ -1234,35 +1305,27 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
let directTypingController: IMessageTypingController | undefined;
const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg);
const sendPolicy = resolveSendPolicy({
cfg,
entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }),
sessionKey: decision.route.sessionKey,
channel: "imessage",
chatType: decision.isGroup ? "group" : "direct",
});
const shouldStartToolTyping =
!decision.isGroup &&
sendPolicy !== "deny" &&
(configuredTypingMode === undefined || configuredTypingMode === "instant");
const directToolTypingOptions = shouldStartToolTyping
const directToolTypingOptions = shouldUseDirectToolTypingOptions
? ({
// iMessage's native typing bubble is channel-owned UI, not a
// visible tool-progress message. The suppress flag is what lets
// dispatch forward this callback even when verbose progress is off;
// allowProgress covers message_tool_only source delivery. Keep this on
// the direct instant/default path so configured typingMode values still
// decide when typing can begin.
// the direct instant/default path even when older imsg builds do not
// report native typing support.
suppressDefaultToolProgressMessages: true,
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
onTypingController: (typing: IMessageTypingController) => {
directTypingController = typing;
typingReplyOptions.onTypingController?.(typing);
},
onToolStart: async () => {
await directTypingController?.startTypingLoop();
},
...(supportsTyping
? {
onToolStart: async () => {
await directTypingController?.startTypingLoop();
},
}
: {}),
} as const)
: {};
const configuredBlockStreaming = resolveChannelStreamingBlockEnabled(accountInfo.config);
@@ -1325,11 +1388,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
historyMap: groupHistories,
limit: historyLimit,
},
onPreDispatchFailure: () =>
settleReplyDispatcher({
onPreDispatchFailure: () => {
stopEarlyDirectTyping?.();
void settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
});
},
runDispatch: async () => {
try {
return await dispatchInboundMessage({
@@ -1348,6 +1413,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
});
} finally {
markDispatchIdle();
stopEarlyDirectTyping?.();
}
},
}),

View File

@@ -40,10 +40,7 @@ import {
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import {
loadSessionStore,
resolveSessionStoreEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
CoreConfig,
@@ -347,12 +344,11 @@ function resolveMatrixSharedDmContextNotice(params: {
}
try {
const store = loadSessionStore(params.storePath);
const currentSession = resolveMatrixStoredSessionMeta(
resolveSessionStoreEntry({
store,
getSessionEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
}).existing,
}),
);
if (!currentSession) {
return null;

View File

@@ -6,11 +6,7 @@ import {
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/channel-core";
import { parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
} from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { resolveMatrixAccountConfig } from "./matrix/account-config.js";
import { resolveDefaultMatrixAccountId } from "./matrix/accounts.js";
import { resolveMatrixStoredSessionMeta } from "./matrix/session-store-metadata.js";
@@ -51,11 +47,10 @@ function resolveMatrixCurrentDmRoomId(params: {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const existing = resolveSessionStoreEntry({
store,
const existing = getSessionEntry({
storePath,
sessionKey,
}).existing;
});
const currentSession = resolveMatrixStoredSessionMeta(existing);
if (!currentSession) {
return undefined;

View File

@@ -46,7 +46,7 @@ export {
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
export { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";

View File

@@ -214,4 +214,66 @@ describe("Mattermost model picker", () => {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it("resolves current and parent model overrides from targeted session entries", () => {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
try {
const storePath = path.join(testDir, "{agentId}.json");
const supportStorePath = path.join(testDir, "support.json");
const parentSessionKey = "agent:support:mattermost:default:channel-1";
const childSessionKey = "agent:support:mattermost:default:child-with-explicit-parent";
const directSessionKey = "agent:support:mattermost:default:direct-1";
fs.writeFileSync(
supportStorePath,
JSON.stringify(
{
[parentSessionKey]: {
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
sessionId: "parent-session",
},
[childSessionKey]: {
parentSessionKey,
sessionId: "child-session",
},
[directSessionKey]: {
providerOverride: "openai",
modelOverride: "gpt-5",
sessionId: "direct-session",
},
},
null,
2,
),
);
const cfg: OpenClawConfig = {
session: {
store: storePath,
},
};
expect(
resolveMattermostModelPickerCurrentModel({
cfg,
route: {
agentId: "support",
sessionKey: directSessionKey,
},
data,
}),
).toBe("openai/gpt-5");
expect(
resolveMattermostModelPickerCurrentModel({
cfg,
route: {
agentId: "support",
sessionKey: childSessionKey,
},
data,
}),
).toBe("anthropic/claude-sonnet-4-5");
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
});

View File

@@ -7,7 +7,7 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
@@ -237,21 +237,28 @@ export function resolveMattermostModelPickerCurrentModel(params: {
cfg: OpenClawConfig;
route: { agentId: string; sessionKey: string };
data: ModelsProviderData;
skipCache?: boolean;
readConsistency?: "latest";
}): string {
const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
try {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionStore = params.skipCache
? loadSessionStore(storePath, { skipCache: true })
: loadSessionStore(storePath);
const sessionEntry = sessionStore[params.route.sessionKey];
const sessionEntry = getSessionEntry({
storePath,
sessionKey: params.route.sessionKey,
...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}),
});
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
loadSessionEntry: (sessionKey) =>
getSessionEntry({
storePath,
sessionKey,
...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}),
}),
sessionKey: params.route.sessionKey,
parentSessionKey: sessionEntry?.parentSessionKey,
defaultProvider: params.data.resolvedDefault.provider,
});
if (!override?.model) {

View File

@@ -1256,7 +1256,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg,
route: modelSessionRoute,
data,
skipCache: true,
readConsistency: "latest",
});
const view = renderMattermostModelsPickerView({
ownerUserId: pickerState.ownerUserId,

View File

@@ -36,7 +36,6 @@ export {
isTrustedProxyAddress,
listSkillCommandsForAgents,
loadOutboundMediaFromUrl,
loadSessionStore,
logInboundDrop,
logTypingFailure,
migrateBaseNameToDefaultAccount,

View File

@@ -24,4 +24,4 @@ export {
listMemoryFiles,
normalizeExtraMemoryPaths,
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
export { getMemorySearchManager } from "./memory/index.js";
export { getMemorySearchManager } from "openclaw/plugin-sdk/memory-core-engine-runtime";

View File

@@ -29,6 +29,30 @@ describe("concept vocabulary", () => {
expect(tags).not.toContain("2026-04-04.md");
});
it("preserves short protected-glossary terms past the latin minimum-length gate", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",
snippet: "Store the session in kv and back up to s3 nightly.",
});
// "kv" and "s3" are 2-char latin glossary entries that the generic min-length-3 gate would drop.
expect(tags).toContain("kv");
expect(tags).toContain("s3");
});
it("does not surface short glossary terms that only appear inside longer words", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",
snippet: "Played the mkv recording and tuned the css3 layout.",
});
// "kv"/"s3" are substrings of "mkv"/"css3"; whole-word matching must not emit them as tags.
expect(tags).not.toContain("kv");
expect(tags).not.toContain("s3");
expect(tags).toContain("mkv");
expect(tags).toContain("css3");
});
it("extracts protected and segmented CJK concept tags", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",

View File

@@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean {
);
}
function normalizeConceptToken(rawToken: string): string | null {
function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null {
const normalized = normalizeLowercaseStringOrEmpty(
rawToken
.normalize("NFKC")
@@ -348,7 +348,9 @@ function normalizeConceptToken(rawToken: string): string | null {
return null;
}
const script = classifyConceptTagScript(normalized);
if (normalized.length < minimumTokenLengthForScript(script)) {
// Glossary entries are an explicit allowlist of short technical terms (e.g. "kv", "s3"); they
// bypass the per-script minimum length that would otherwise discard them.
if (!fromGlossary && normalized.length < minimumTokenLengthForScript(script)) {
return null;
}
if (isKanaOnlyToken(normalized) && normalized.length < 3) {
@@ -360,14 +362,43 @@ function normalizeConceptToken(rawToken: string): string | null {
return normalized;
}
// Only entries shorter than their script's minimum token length rely on the glossary bypass, and
// only those need whole-word matching so they don't fire inside longer words ("kv" in "mkv"). Longer
// entries keep substring containment (the shipped behavior, e.g. "backup" tagging inside "backups").
// Precomputed so derive() does not reclassify on every call.
const GLOSSARY_ENTRIES = PROTECTED_GLOSSARY.map((entry) => ({
entry,
wholeWord: entry.length < minimumTokenLengthForScript(classifyConceptTagScript(entry)),
}));
function isAlphanumericAt(source: string, index: number): boolean {
const ch = source[index];
return ch !== undefined && LETTER_OR_NUMBER_RE.test(ch);
}
// True when `entry` occurs as a delimiter-bounded token, not inside a longer word. Keeps short
// glossary entries like "kv"/"s3" from firing inside "mkv"/"css3" once they bypass the length gate.
function includesStandaloneTerm(source: string, entry: string): boolean {
let from = source.indexOf(entry);
while (from !== -1) {
if (!isAlphanumericAt(source, from - 1) && !isAlphanumericAt(source, from + entry.length)) {
return true;
}
from = source.indexOf(entry, from + 1);
}
return false;
}
function collectGlossaryMatches(source: string): string[] {
const normalizedSource = normalizeLowercaseStringOrEmpty(source.normalize("NFKC"));
const matches: string[] = [];
for (const entry of PROTECTED_GLOSSARY) {
if (!normalizedSource.includes(entry)) {
continue;
for (const { entry, wholeWord } of GLOSSARY_ENTRIES) {
const present = wholeWord
? includesStandaloneTerm(normalizedSource, entry)
: normalizedSource.includes(entry);
if (present) {
matches.push(entry);
}
matches.push(entry);
}
return matches;
}
@@ -385,8 +416,13 @@ function collectSegmentTokens(source: string): string[] {
return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void {
const normalized = normalizeConceptToken(rawToken);
function pushNormalizedTag(
tags: string[],
rawToken: string,
limit: number,
fromGlossary = false,
): void {
const normalized = normalizeConceptToken(rawToken, fromGlossary);
if (!normalized || tags.includes(normalized)) {
return;
}
@@ -410,14 +446,17 @@ export function deriveConceptTags(params: {
}
const tags: string[] = [];
for (const rawToken of [
...collectGlossaryMatches(source),
...collectCompoundTokens(source),
...collectSegmentTokens(source),
]) {
pushNormalizedTag(tags, rawToken, limit);
if (tags.length >= limit) {
break;
const tokenSources: Array<{ tokens: string[]; fromGlossary: boolean }> = [
{ tokens: collectGlossaryMatches(source), fromGlossary: true },
{ tokens: collectCompoundTokens(source), fromGlossary: false },
{ tokens: collectSegmentTokens(source), fromGlossary: false },
];
for (const { tokens, fromGlossary } of tokenSources) {
for (const rawToken of tokens) {
pushNormalizedTag(tags, rawToken, limit, fromGlossary);
if (tags.length >= limit) {
return tags;
}
}
}
return tags;

View File

@@ -1987,6 +1987,78 @@ describe("memory-core dreaming phases", () => {
expect(newOccurrences).toBe(1);
});
it("skips reset/deleted archive artifacts without active transcripts during session ingestion", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const archivePath = path.join(
sessionsDir,
"archived-only.jsonl.deleted.2026-04-06T01-00-00.000Z",
);
await fs.writeFile(
archivePath,
[
JSON.stringify({
type: "message",
message: {
role: "user",
timestamp: "2026-04-05T18:01:00.000Z",
content: [{ type: "text", text: "Archived session should not be dreamed." }],
},
}),
].join("\n") + "\n",
"utf-8",
);
const mtime = new Date("2026-04-06T01:05:00.000Z");
await fs.utimes(archivePath, mtime, mtime);
const { beforeAgentReply } = createHarness(
{
agents: {
defaults: {
workspace: workspaceDir,
},
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 7,
},
},
},
},
},
},
},
},
workspaceDir,
);
try {
await withDreamingTestClock(async () => {
await triggerLightDreaming(beforeAgentReply, workspaceDir, 5);
});
} finally {
vi.unstubAllEnvs();
}
await expectPathMissing(
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
);
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
expect(Object.keys(sessionIngestion.files)).toHaveLength(0);
});
it("buckets session snippets by per-message day rather than file mtime", async () => {
const workspaceDir = await createDreamingWorkspace();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");

View File

@@ -848,7 +848,12 @@ async function collectSessionIngestionBatches(params: {
for (const agentId of agentIds) {
for (const entry of await listSessionTranscriptCorpusEntriesForAgent(agentId)) {
const absolutePath = entry.sessionFile;
if (isCheckpointSessionTranscriptPath(absolutePath)) {
if (
// Dreaming learns only from the live corpus. Retained reset/delete
// archives stay in the shared corpus for QMD and memory_search.
entry.artifactKind === "archive-artifact" ||
isCheckpointSessionTranscriptPath(absolutePath)
) {
continue;
}
sessionFiles.push({

View File

@@ -199,11 +199,17 @@ vi.mock("openclaw/plugin-sdk/file-lock", async () => {
import { spawn as mockedSpawn } from "node:child_process";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import {
type MemorySearchRuntimeDebug,
requireNodeSqlite,
resolveMemoryBackendConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js";
import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js";
@@ -257,6 +263,14 @@ describe("QmdMemoryManager", () => {
return mock.mock.calls.map((call: unknown[]) => String(call[0]));
}
function qmdCommandCalls(): string[][] {
return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
}
function countQmdCommand(predicate: (args: string[]) => boolean): number {
return qmdCommandCalls().filter(predicate).length;
}
function expectMockMessageContains(mock: Mock, text: string): void {
expect(mockMessages(mock).join("\n")).toContain(text);
}
@@ -277,6 +291,246 @@ describe("QmdMemoryManager", () => {
);
});
it("reuses persisted collection validation across transient cli managers", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0);
});
it("does not cache incomplete collection validation", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "add") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "permission denied", 1);
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.close();
spawnMock.mockClear();
spawnMock.mockImplementation(() => createMockChild());
const second = await createManager({ mode: "cli" });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("runs collection validation when the runtime cache store is unavailable", async () => {
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
const manager = await createManager({ mode: "cli" });
await manager.manager.close();
} finally {
await configureMemoryCoreDreamingStateForTests();
}
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
});
it("reports collection validation debug only once per validation run", async () => {
await configureMemoryCoreDreamingStateForTests();
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined();
});
it("misses collection validation cache when managed collection config changes", async () => {
await configureMemoryCoreDreamingStateForTests();
const first = await createManager({ mode: "cli" });
await first.manager.close();
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
await fs.mkdir(otherWorkspaceDir, { recursive: true });
const changedCfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
...(cfg.memory?.qmd ?? {}),
paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockClear();
const second = await createManager({ mode: "cli", cfg: changedCfg });
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
});
it("bypasses validation cache for missing-collection search repair", async () => {
await configureMemoryCoreDreamingStateForTests();
const { manager } = await createManager();
spawnMock.mockClear();
let searchAttempts = 0;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
const child = createMockChild({ autoClose: false });
searchAttempts += 1;
if (searchAttempts === 1) {
emitAndClose(child, "stderr", "collection workspace-main not found", 1);
} else {
emitAndClose(child, "stdout", "[]");
}
return child;
}
return createMockChild();
});
const debug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
expect(searchAttempts).toBe(2);
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force");
});
it("reuses persisted qmd multi-collection support probe across managers", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const first = await createManager({ mode: "cli" });
await first.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
});
await first.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(1);
spawnMock.mockClear();
const second = await createManager({ mode: "cli" });
const debug: MemorySearchRuntimeDebug[] = [];
await second.manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
debug.push(entry);
},
});
await second.manager.close();
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit");
expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2);
});
it("reports multi-collection probe debug only when the probe runs", async () => {
await configureMemoryCoreDreamingStateForTests();
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager({ mode: "cli" });
const firstDebug: MemorySearchRuntimeDebug[] = [];
const secondDebug: MemorySearchRuntimeDebug[] = [];
await manager.search("fact", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
firstDebug.push(entry);
},
});
await manager.search("fact again", {
sessionKey: "agent:main:slack:dm:u123",
onDebug: (entry) => {
secondDebug.push(entry);
},
});
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write");
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined();
});
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.lstat(targetPath);
@@ -406,6 +660,7 @@ describe("QmdMemoryManager", () => {
delete (globalThis as Record<PropertyKey, unknown>)[MCPORTER_STATE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[QMD_EMBED_QUEUE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[MEMORY_EMBEDDING_PROVIDERS_KEY];
resetMemoryCoreDreamingStateForTests();
});
it("debounces back-to-back sync calls", async () => {

View File

@@ -74,6 +74,15 @@ import {
type QmdSessionArtifactMapping,
} from "../qmd-session-artifacts.js";
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
import {
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
} from "./qmd-runtime-cache.js";
import {
countChokidarWatchedEntries,
type MemoryWatchPressureWarningState,
@@ -324,6 +333,14 @@ type ManagedCollection = {
kind: "memory" | "custom" | "sessions";
};
type QmdCollectionValidationDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["collectionValidation"]
>;
type QmdMultiCollectionProbeDebug = NonNullable<
NonNullable<MemorySearchRuntimeDebug["qmd"]>["multiCollectionProbe"]
>;
type QmdSearchPlanDebug = NonNullable<NonNullable<MemorySearchRuntimeDebug["qmd"]>["searchPlan"]>;
type QmdManagerMode = "full" | "status" | "cli";
type QmdManagerRuntimeConfig = {
workspaceDir: string;
@@ -453,6 +470,9 @@ export class QmdMemoryManager implements MemorySearchManager {
private readonly sessionWarm = new Set<string>();
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
private multiCollectionFilterSupported: boolean | null = null;
private pendingCollectionValidationDebug: QmdCollectionValidationDebug | undefined;
private currentSearchMultiCollectionProbeDebug: QmdMultiCollectionProbeDebug | undefined;
private currentSearchPlanDebug: QmdSearchPlanDebug | undefined;
private constructor(params: {
agentId: string;
@@ -612,11 +632,118 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
private async ensureCollections(): Promise<void> {
private qmdRuntimeCacheSources(): string[] {
return [...this.sources].toSorted();
}
private qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] {
return this.qmd.collections.map((collection) => ({
name: collection.name,
kind: collection.kind,
path: collection.path,
pattern: collection.pattern,
}));
}
private buildQmdCollectionValidationCacheContext(): QmdRuntimeCollectionValidationCacheContext {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
collections: this.qmdRuntimeCacheCollections(),
sources: this.qmdRuntimeCacheSources(),
};
}
private buildQmdMultiCollectionProbeCacheContext(): QmdRuntimeMultiCollectionProbeCacheContext {
return {
workspaceDir: this.workspaceDir,
agentId: this.agentId,
qmdCommand: this.qmd.command,
qmdIndexPath: this.indexPath,
searchMode: this.qmd.searchMode,
sources: this.qmdRuntimeCacheSources(),
};
}
private recordSearchPlanDebug(params: {
command: "query" | "search" | "vsearch";
collectionNames: string[];
collectionGroups: string[][];
}): void {
const sources = uniqueValues(
params.collectionNames
.map((collectionName) => this.collectionRoots.get(collectionName)?.kind)
.filter((source): source is MemorySource => Boolean(source)),
);
this.currentSearchPlanDebug = {
command: params.command,
collectionCount: params.collectionNames.length,
groupCount: params.collectionGroups.length,
sources,
};
}
private resetQmdSearchRuntimeDebug(): void {
this.currentSearchMultiCollectionProbeDebug = undefined;
this.currentSearchPlanDebug = undefined;
}
private consumeQmdRuntimeDebug(): MemorySearchRuntimeDebug["qmd"] | undefined {
const debug: NonNullable<MemorySearchRuntimeDebug["qmd"]> = {};
if (this.pendingCollectionValidationDebug) {
debug.collectionValidation = this.pendingCollectionValidationDebug;
}
if (this.currentSearchMultiCollectionProbeDebug) {
debug.multiCollectionProbe = this.currentSearchMultiCollectionProbeDebug;
}
if (this.currentSearchPlanDebug) {
debug.searchPlan = this.currentSearchPlanDebug;
}
this.pendingCollectionValidationDebug = undefined;
this.currentSearchMultiCollectionProbeDebug = undefined;
this.currentSearchPlanDebug = undefined;
return Object.keys(debug).length > 0 ? debug : undefined;
}
private async ensureCollectionPathsBestEffort(): Promise<void> {
for (const collection of this.qmd.collections) {
try {
await this.ensureCollectionPath(collection);
} catch (err) {
log.warn(
`qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`,
);
}
}
}
private async ensureCollections(options?: { force?: boolean }): Promise<void> {
const startedAt = Date.now();
const cacheContext = this.buildQmdCollectionValidationCacheContext();
if (!options?.force) {
const cached = await readQmdCollectionValidationCache(cacheContext);
if (cached.state === "hit") {
await this.ensureCollectionPathsBestEffort();
this.pendingCollectionValidationDebug = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: cached.value.validation.collectionCount,
listCalls: 0,
showCalls: 0,
};
return;
}
}
const stats = { listCalls: 0, showCalls: 0 };
let validationComplete = true;
// QMD collections are persisted inside the index database and must be created
// via the CLI. Prefer listing existing collections when supported, otherwise
// fall back to best-effort idempotent `qmd collection add`.
const existing = await this.listCollectionsBestEffort();
const existing = await this.listCollectionsBestEffort(stats);
await this.migrateLegacyUnscopedCollections(existing);
@@ -631,6 +758,7 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) {
const message = formatErrorMessage(err);
if (!this.isCollectionMissingError(message)) {
validationComplete = false;
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
}
}
@@ -661,13 +789,31 @@ export class QmdMemoryManager implements MemorySearchManager {
pattern: collection.pattern,
});
} else {
validationComplete = false;
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
}
continue;
}
validationComplete = false;
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
const wroteCache = validationComplete
? await writeQmdCollectionValidationCache(cacheContext)
: false;
this.pendingCollectionValidationDebug = {
cacheState: validationComplete
? options?.force
? "bypass-force"
: wroteCache
? "write"
: "error"
: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
collectionCount: this.qmd.collections.length,
listCalls: stats.listCalls,
showCalls: stats.showCalls,
};
}
private async tryRebindSameNameCollection(params: {
@@ -713,9 +859,15 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
private async listCollectionsBestEffort(stats?: {
listCalls: number;
showCalls: number;
}): Promise<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
try {
if (stats) {
stats.listCalls += 1;
}
const result = await this.runQmd(["collection", "list", "--json"], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -737,6 +889,9 @@ export class QmdMemoryManager implements MemorySearchManager {
continue;
}
try {
if (stats) {
stats.showCalls += 1;
}
const showResult = await this.runQmd(["collection", "show", collection.name], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
@@ -963,7 +1118,7 @@ export class QmdMemoryManager implements MemorySearchManager {
log.warn(
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
);
await this.ensureCollections();
await this.ensureCollections({ force: true });
return true;
}
@@ -1318,6 +1473,7 @@ export class QmdMemoryManager implements MemorySearchManager {
if (searchSignal?.aborted) {
throw asAbortError(searchSignal);
}
this.resetQmdSearchRuntimeDebug();
const trimmed = query.trim();
if (!trimmed) {
return [];
@@ -1403,6 +1559,11 @@ export class QmdMemoryManager implements MemorySearchManager {
collectionNames,
searchSignal,
);
this.recordSearchPlanDebug({
command: qmdSearchCommand,
collectionNames,
collectionGroups,
});
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1434,6 +1595,11 @@ export class QmdMemoryManager implements MemorySearchManager {
collectionNames,
searchSignal,
);
this.recordSearchPlanDebug({
command: "query",
collectionNames,
collectionGroups,
});
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
@@ -1512,6 +1678,7 @@ export class QmdMemoryManager implements MemorySearchManager {
configuredMode: qmdSearchCommand,
effectiveMode: effectiveSearchMode,
fallback: searchFallbackReason,
qmd: this.consumeQmdRuntimeDebug(),
});
let ranked = results;
if (opts?.sources?.length) {
@@ -3387,6 +3554,18 @@ export class QmdMemoryManager implements MemorySearchManager {
if (this.multiCollectionFilterSupported !== null) {
return this.multiCollectionFilterSupported;
}
const startedAt = Date.now();
const cacheContext = this.buildQmdMultiCollectionProbeCacheContext();
const cached = await readQmdMultiCollectionProbeCache(cacheContext);
if (cached.state === "hit") {
this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported;
this.currentSearchMultiCollectionProbeDebug = {
cacheState: "hit",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
return this.multiCollectionFilterSupported;
}
try {
const result = await this.runQmd(["--help"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
@@ -3395,12 +3574,26 @@ export class QmdMemoryManager implements MemorySearchManager {
const helpText = `${result.stdout}\n${result.stderr}`;
this.multiCollectionFilterSupported =
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
const wroteCache = await writeQmdMultiCollectionProbeCache(
cacheContext,
this.multiCollectionFilterSupported,
);
this.currentSearchMultiCollectionProbeDebug = {
cacheState: wroteCache ? "write" : "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: this.multiCollectionFilterSupported,
};
} catch (err) {
// Cancellation says nothing about QMD capabilities; leave the probe uncached.
if (signal?.aborted) {
throw asAbortError(signal);
}
this.multiCollectionFilterSupported = false;
this.currentSearchMultiCollectionProbeDebug = {
cacheState: "error",
elapsedMs: Math.max(0, Date.now() - startedAt),
supported: false,
};
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
}
return this.multiCollectionFilterSupported;

View File

@@ -0,0 +1,289 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
openMemoryCoreStateStore,
memoryCoreWorkspaceEntryKey,
resetMemoryCoreDreamingStateForTests,
} from "../dreaming-state.js";
import {
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
buildQmdMultiCollectionProbeCacheContextHash,
clearQmdCollectionValidationCache,
clearQmdMultiCollectionProbeCache,
readQmdCollectionValidationCache,
readQmdMultiCollectionProbeCache,
type QmdRuntimeCollectionValidationCacheContext,
type QmdRuntimeManagedCollection,
type QmdRuntimeMultiCollectionProbeCacheContext,
writeQmdCollectionValidationCache,
writeQmdMultiCollectionProbeCache,
} from "./qmd-runtime-cache.js";
const tempRoots: string[] = [];
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
});
afterAll(async () => {
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) {
await fs.rm(root, { recursive: true, force: true });
}
}
resetMemoryCoreDreamingStateForTests();
});
async function clearStore(namespace: string): Promise<void> {
try {
await openMemoryCoreStateStore({
namespace,
maxEntries: 1_000,
}).clear();
} catch {
// fail open
}
}
afterEach(async () => {
await clearStore(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE);
await clearStore(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE);
});
function makeWorkspace(): Promise<string> {
const prefix = path.join(os.tmpdir(), `qmd-runtime-cache-${Date.now()}-`);
return fs.mkdtemp(prefix).then((workspaceDir) => {
tempRoots.push(workspaceDir);
return workspaceDir;
});
}
function managedCollections(): QmdRuntimeManagedCollection[] {
return [
{
name: "project-notes",
kind: "memory",
path: "/repo/project-notes",
pattern: "*.md",
},
{
name: "sessions",
kind: "sessions",
path: "/repo/sessions",
pattern: "*",
},
];
}
function collectionValidationContext(
workspaceDir: string,
): QmdRuntimeCollectionValidationCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
collections: managedCollections(),
sources: ["memory", "sessions"],
};
}
function multiCollectionProbeContext(
workspaceDir: string,
): QmdRuntimeMultiCollectionProbeCacheContext {
return {
workspaceDir,
agentId: "agent-a",
qmdCommand: "qmd",
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
searchMode: "search",
sources: ["memory", "sessions"],
};
}
describe("qmd-runtime-cache", () => {
it("writes and reads collection validation cache entries", async () => {
const workspaceDir = await makeWorkspace();
const context = collectionValidationContext(workspaceDir);
const writeStartedAtMs = 1_000;
const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdCollectionValidationCache(
{ ...context, sources: ["sessions", "memory"] },
writeStartedAtMs + 1,
);
expect(read).toMatchObject({
state: "hit",
value: {
validation: {
ok: true,
collectionCount: context.collections.length,
},
},
});
});
it("writes and reads multi-collection probe cache entries", async () => {
const workspaceDir = await makeWorkspace();
const context = multiCollectionProbeContext(workspaceDir);
const writeStartedAtMs = 2_000;
const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs);
expect(writeOk).toBe(true);
const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1);
expect(read).toMatchObject({
state: "hit",
value: {
multiCollectionProbe: {
supported: true,
},
},
});
});
it("scopes cache entries by workspace", async () => {
const firstWorkspace = await makeWorkspace();
const secondWorkspace = await makeWorkspace();
const context = collectionValidationContext(firstWorkspace);
expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true);
const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = {
...context,
workspaceDir: secondWorkspace,
qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"),
};
const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001);
expect(miss).toStrictEqual({ state: "miss" });
});
it("misses collection validation cache when managed collection paths change", async () => {
const workspaceDir = await makeWorkspace();
const context = collectionValidationContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true);
const changedContext: QmdRuntimeCollectionValidationCacheContext = {
...context,
collections: context.collections.map((collection) =>
collection.name === "project-notes"
? { ...collection, path: `${collection.path}-moved` }
: collection,
),
};
expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({
state: "miss",
});
});
it("treats cache misses for malformed values and expired entries", async () => {
const workspaceDir = await makeWorkspace();
const context = multiCollectionProbeContext(workspaceDir);
const nowMs = 4_000;
await writeQmdMultiCollectionProbeCache(context, false, nowMs);
const key = memoryCoreWorkspaceEntryKey(
workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(context)}`,
);
const store = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
await store.register(key, {
version: 1,
createdAtMs: "bad",
expiresAtMs: 0,
keyHash: "bad",
multiCollectionProbe: { supported: true },
});
const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1);
expect(malformed).toStrictEqual({ state: "miss" });
const expired = await readQmdMultiCollectionProbeCache(
context,
nowMs + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS + 1,
);
expect(expired).toStrictEqual({ state: "miss" });
});
it("uses separate namespaces for validation and probe entries", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true);
const validationStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: 1_000,
});
const probeStore = openMemoryCoreStateStore({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: 1_000,
});
expect((await validationStore.entries()).length).toBeGreaterThan(0);
expect((await probeStore.entries()).length).toBeGreaterThan(0);
});
it("fails open when state store is unavailable", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
configureMemoryCoreDreamingState(() => {
throw new Error("state store unavailable");
});
try {
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false);
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false);
} finally {
await configureMemoryCoreDreamingStateForTests();
}
});
it("exposes bounded TTL windows", () => {
expect(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS).toBe(5 * 60_000);
expect(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS).toBe(10 * 60_000);
});
it("can clear cache keys explicitly", async () => {
const workspaceDir = await makeWorkspace();
const validationContext = collectionValidationContext(workspaceDir);
const probeContext = multiCollectionProbeContext(workspaceDir);
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(true);
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true);
await clearQmdCollectionValidationCache(validationContext);
await clearQmdMultiCollectionProbeCache(probeContext);
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
state: "miss",
});
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
});
});

View File

@@ -0,0 +1,432 @@
// Memory Core QMD runtime cache helpers.
import { createHash } from "node:crypto";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE =
"qmd-runtime-cache.collection-validation";
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
"qmd-runtime-cache.multi-collection-probe";
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1;
export type QmdRuntimeManagedCollection = {
name: string;
kind: "memory" | "custom" | "sessions";
path: string;
pattern: string;
};
type QmdRuntimeCacheContextBase = {
workspaceDir: string;
agentId: string;
qmdCommand: string;
qmdVersion?: string;
qmdIndexPath: string;
searchMode: string;
};
export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & {
collections: readonly QmdRuntimeManagedCollection[];
sources: readonly string[];
};
export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & {
sources: readonly string[];
};
export type QmdRuntimeCacheCollectionValidationEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
validation: {
ok: true;
collectionConfigHash: string;
collectionCount: number;
};
};
export type QmdRuntimeCacheMultiCollectionProbeEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
multiCollectionProbe: {
supported: boolean;
};
};
export type QmdRuntimeCacheResult<T> =
| {
state: "hit";
value: T;
}
| { state: "miss" };
function normalizeText(value: string): string {
return value.trim();
}
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
return {
name: normalizeText(collection.name),
kind: collection.kind,
pathHash: normalizePathIdentity(collection.path),
pattern: normalizeText(collection.pattern),
};
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function normalizePathIdentity(value: string): string {
const normalized =
process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value);
return hashText(normalized);
}
function sortedUnique(values: readonly string[]): string[] {
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted();
}
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
const normalized = collections
.map((collection) => ({
...normalizeCollection(collection),
}))
.toSorted(
(left, right) =>
left.name.localeCompare(right.name) ||
left.kind.localeCompare(right.kind) ||
left.pathHash.localeCompare(right.pathHash) ||
left.pattern.localeCompare(right.pattern),
)
.map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`)
.join(";");
return hashText(normalized);
}
function buildCollectionValidationCacheContextInput(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
collectionConfigHash: buildCollectionConfigHash(params.collections),
});
}
function buildMultiCollectionProbeCacheContextInput(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return JSON.stringify({
agentId: normalizeText(params.agentId),
commandHash: hashText(normalizeText(params.qmdCommand)),
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
qmdVersion: normalizeText(params.qmdVersion ?? ""),
searchMode: params.searchMode,
sourceSet: sortedUnique(params.sources),
});
}
function buildCollectionValidationCacheHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return hashText(buildCollectionValidationCacheContextInput(params));
}
function buildMultiCollectionProbeCacheHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return hashText(buildMultiCollectionProbeCacheContextInput(params));
}
export function buildQmdCollectionValidationCacheContextHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return buildCollectionValidationCacheHash(params);
}
export function buildQmdMultiCollectionProbeCacheContextHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return buildMultiCollectionProbeCacheHash(params);
}
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
});
}
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
});
}
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.collection-validation:${buildCollectionValidationCacheHash(params)}`,
);
}
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildMultiCollectionProbeCacheHash(params)}`,
);
}
function normalizeCollectionValidationEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheCollectionValidationEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const validation = record.validation as unknown;
if (typeof validation !== "object" || validation === null) {
return undefined;
}
const validationRecord = validation as Record<string, unknown>;
if (validationRecord.ok !== true) {
return undefined;
}
if (typeof validationRecord.collectionConfigHash !== "string") {
return undefined;
}
if (typeof validationRecord.collectionCount !== "number") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
validation: {
ok: true,
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
},
};
}
function normalizeMultiCollectionProbeEntry(
value: unknown,
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheMultiCollectionProbeEntry | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
const createdAtMs =
typeof record.createdAtMs === "number"
? Math.max(0, Math.floor(record.createdAtMs))
: Number.NaN;
const expiresAtMs =
typeof record.expiresAtMs === "number"
? Math.max(0, Math.floor(record.expiresAtMs))
: Number.NaN;
if (
!Number.isFinite(createdAtMs) ||
!Number.isFinite(expiresAtMs) ||
!Number.isFinite(nowMs) ||
nowMs >= expiresAtMs
) {
return undefined;
}
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
if (keyHash !== expectedKeyHash) {
return undefined;
}
const probe = record.multiCollectionProbe as unknown;
if (typeof probe !== "object" || probe === null) {
return undefined;
}
const probeRecord = probe as Record<string, unknown>;
if (typeof probeRecord.supported !== "boolean") {
return undefined;
}
return {
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs,
keyHash,
multiCollectionProbe: {
supported: probeRecord.supported,
},
};
}
export async function readQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
try {
const store = collectionValidationStore();
const key = collectionValidationEntryKey(params);
const expectedKeyHash = buildCollectionValidationCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = collectionValidationEntryKey(params);
const keyHash = buildCollectionValidationCacheHash(params);
const collectionConfigHash = buildCollectionConfigHash(params.collections);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
const store = collectionValidationStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
validation: {
ok: true,
collectionConfigHash,
collectionCount: params.collections.length,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
): Promise<void> {
try {
const store = collectionValidationStore();
await store.delete(collectionValidationEntryKey(params));
} catch {
// fail open
}
}
export async function readQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
try {
const store = multiCollectionProbeStore();
const key = multiCollectionProbeEntryKey(params);
const expectedKeyHash = buildMultiCollectionProbeCacheHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
supported: boolean,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = multiCollectionProbeEntryKey(params);
const keyHash = buildMultiCollectionProbeCacheHash(params);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
const store = multiCollectionProbeStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
multiCollectionProbe: {
supported,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
}
export async function clearQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): Promise<void> {
try {
const store = multiCollectionProbeStore();
await store.delete(multiCollectionProbeEntryKey(params));
} catch {
// fail open
}
}

View File

@@ -326,6 +326,10 @@ describe("getMemorySearchManager caching", () => {
expect(first.manager).toBe(second.manager);
expect(createQmdManagerMock.mock.calls).toHaveLength(1);
expect(first.debug?.managerCacheState).toBe("cached-full-miss");
expect(second.debug?.managerCacheState).toBe("cached-full-hit");
expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash);
});
it("keeps the cached QMD manager active when the caller cancels a search", async () => {
@@ -806,6 +810,10 @@ describe("getMemorySearchManager caching", () => {
const fullManager = requireManager(full);
const cliManager = requireManager(cli);
expect(cli.debug?.managerCacheState).toBe("transient-cli");
expect(full.debug?.managerCacheState).toBe("cached-full-miss");
expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash);
expect(cliManager).toBe(cliPrimary);
expect(cliManager).not.toBe(fullManager);
const fullCreateParams = qmdCreateParams();

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
// Memory Core plugin module implements search manager behavior.
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -48,6 +49,24 @@ type QmdManagerOpenFailure = {
retryAfterMs: number;
};
type MemorySearchManagerCacheState =
| "cached-full-hit"
| "cached-full-miss"
| "transient-cli"
| "transient-status"
| "pending-create-wait"
| "fallback-builtin"
| "recent-failure-cooldown";
export type MemorySearchManagerDebug = {
backend?: "builtin" | "qmd";
purpose?: MemorySearchManagerPurpose;
managerMs?: number;
managerCacheState?: MemorySearchManagerCacheState;
qmdIdentityHash?: string;
failureCode?: "qmd-unavailable";
};
type MemorySearchManagerCacheStore = {
qmdManagerCache: Map<string, CachedQmdManagerEntry>;
pendingQmdManagerCreates: Map<string, PendingQmdManagerCreate>;
@@ -109,6 +128,7 @@ function loadQmdManagerModule() {
export type MemorySearchManagerResult = {
manager: Maybe<MemorySearchManager>;
error?: string;
debug?: MemorySearchManagerDebug;
};
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
@@ -149,11 +169,42 @@ function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void
}
}
function hashQmdManagerIdentity(identityKey: string): string {
return createHash("sha256").update(identityKey).digest("hex");
}
function applyManagerDebug(
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult {
if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) {
return result;
}
return {
...result,
debug: {
...(result.debug ?? {}),
...debug,
},
};
}
export async function getMemorySearchManager(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: MemorySearchManagerPurpose;
}): Promise<MemorySearchManagerResult> {
const acquireStartedAt = Date.now();
const purpose = params.purpose ?? "default";
const finish = (
result: MemorySearchManagerResult,
debug: MemorySearchManagerDebug,
): MemorySearchManagerResult =>
applyManagerDebug(result, {
purpose,
managerMs: Math.max(0, Date.now() - acquireStartedAt),
...debug,
});
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const qmdResolved = resolved.qmd;
@@ -163,6 +214,7 @@ export async function getMemorySearchManager(params: {
const transient = params.purpose === "status" || params.purpose === "cli";
const scopeKey = buildQmdManagerScopeKey(normalizedAgentId);
const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig);
const debugIdentityHash = hashQmdManagerIdentity(identityKey);
const createPrimaryQmdManager = async (
mode: "full" | "status" | "cli",
@@ -254,10 +306,24 @@ export async function getMemorySearchManager(params: {
// Status callers often close the manager they receive. Wrap the live
// full manager with a no-op close so health/status probes do not tear
// down the active QMD manager for the process.
return { manager: new BorrowedMemoryManager(cached.manager) };
return finish(
{ manager: new BorrowedMemoryManager(cached.manager) },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
}
if (params.purpose !== "cli") {
return { manager: cached.manager };
return finish(
{ manager: cached.manager },
{
backend: "qmd",
managerCacheState: "cached-full-hit",
qmdIdentityHash: debugIdentityHash,
},
);
}
}
@@ -266,20 +332,44 @@ export async function getMemorySearchManager(params: {
params.purpose === "cli" ? "cli" : "status",
);
return manager
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
}
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
if (recentFailure) {
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
return finish(
await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason),
{
backend: "qmd",
managerCacheState: "recent-failure-cooldown",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
},
);
}
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
if (pending) {
await pending.promise;
return await getMemorySearchManager(params);
return finish(await getMemorySearchManager(params), {
backend: "qmd",
managerCacheState: "pending-create-wait",
qmdIdentityHash: debugIdentityHash,
});
}
let pendingFailureReason: string | undefined;
@@ -309,11 +399,25 @@ export async function getMemorySearchManager(params: {
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
const manager = await pendingCreate.promise;
return manager
? { manager }
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
? finish(
{ manager },
{
backend: "qmd",
managerCacheState: "cached-full-miss",
qmdIdentityHash: debugIdentityHash,
},
)
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), {
backend: "qmd",
managerCacheState: "fallback-builtin",
qmdIdentityHash: debugIdentityHash,
failureCode: "qmd-unavailable",
});
}
return await getBuiltinMemorySearchManager(params);
return finish(await getBuiltinMemorySearchManager(params), {
backend: "builtin",
});
}
async function getBuiltinMemorySearchManagerAfterQmdFailure(

View File

@@ -3189,7 +3189,9 @@ describe("short-term promotion", () => {
path: "memory/2026-04-03.md",
snippet: "Move backups to S3 Glacier and sync QMD router notes.",
}),
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "sync"]);
// "s3" is a protected-glossary term; it now surfaces as a standalone token past the
// per-script min-length gate (the longer terms still match as substrings).
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "s3", "sync"]);
});
it("extracts multilingual concept tags across latin and cjk snippets", () => {

View File

@@ -67,18 +67,28 @@ export async function getMemoryManagerContextWithPurpose(params: {
}): Promise<
| {
manager: NonNullable<MemorySearchManagerResult["manager"]>;
debug?: NonNullable<MemorySearchManagerResult["debug"]>;
}
| {
error: string | undefined;
}
> {
const { getMemorySearchManager } = await loadMemoryToolRuntime();
const { manager, error } = await getMemorySearchManager({
const startedAt = Date.now();
const { manager, debug, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager ? { manager } : { error };
return manager
? {
manager,
debug: {
...debug,
managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt),
},
}
: { error };
}
export function createMemoryTool(params: {

View File

@@ -422,6 +422,14 @@ describe("memory_search unavailable payloads", () => {
configuredMode: opts.qmdSearchModeOverride ?? "query",
effectiveMode: "query",
fallback: "unsupported-search-flags",
qmd: {
searchPlan: {
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
},
},
});
return [
{
@@ -470,6 +478,18 @@ describe("memory_search unavailable payloads", () => {
fallback?: unknown;
hits?: unknown;
searchMs?: number;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
managerCacheState?: unknown;
qmd?: {
searchPlan?: {
command?: unknown;
collectionCount?: unknown;
groupCount?: unknown;
sources?: unknown;
};
};
};
};
expect(details.mode).toBe("query");
@@ -479,6 +499,94 @@ describe("memory_search unavailable payloads", () => {
expect(details.debug?.fallback).toBe("unsupported-search-flags");
expect(details.debug?.hits).toBe(1);
expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBeUndefined();
expect(details.debug?.qmd?.searchPlan).toEqual({
command: "query",
collectionCount: 2,
groupCount: 2,
sources: ["memory", "sessions"],
});
});
it("includes manager acquisition timing and cache-state debug payload", async () => {
setMemorySearchManagerImpl(
async () =>
({
manager: {
search: vi.fn(async () => {
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
];
}),
readFile: vi.fn(),
status: vi.fn(() => ({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/workspace",
dbPath: "/tmp/workspace/index.sqlite",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
},
debug: {
managerMs: 17,
managerCacheState: "cached-full-hit",
},
}) as any,
);
setMemorySearchImpl(async () => [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
]);
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { backend: "qmd" },
},
});
const result = await tool.execute("manager-debug", { query: "favorite food" });
const details = result.details as {
debug?: {
backend?: string;
managerMs?: number;
toolMs?: number;
outsideSearchMs?: number;
managerCacheState?: string;
hits?: number;
searchMs?: number;
};
};
expect(details.debug?.backend).toBe("qmd");
expect(details.debug?.managerMs).toBe(17);
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
expect(details.debug?.managerCacheState).toBe("cached-full-hit");
});
});

View File

@@ -415,6 +415,7 @@ export function createMemorySearchTool(options: {
const outcome = await runMemorySearchToolWithDeadline({
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
run: async (deadlineSignal) => {
const toolStartedAt = Date.now();
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
@@ -471,13 +472,20 @@ export function createMemorySearchTool(options: {
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let managerMs: number | undefined;
let managerCacheState: string | undefined;
let searchDebug:
| {
backend: string;
configuredMode?: string;
effectiveMode?: string;
fallback?: string;
toolMs?: number;
managerMs?: number;
outsideSearchMs?: number;
searchMs: number;
managerCacheState?: string;
qmd?: MemorySearchRuntimeDebug["qmd"];
hits: number;
}
| undefined;
@@ -506,6 +514,8 @@ export function createMemorySearchTool(options: {
},
...(searchSources ? { sources: searchSources } : {}),
};
managerMs = memory.debug?.managerMs;
managerCacheState = memory.debug?.managerCacheState;
try {
rawResults = await activeMemory.manager.search(query, searchOptions);
} catch (error) {
@@ -522,6 +532,8 @@ export function createMemorySearchTool(options: {
if ("error" in refreshed) {
throw error;
}
managerMs = refreshed.debug?.managerMs;
managerCacheState = refreshed.debug?.managerCacheState;
activeMemory = refreshed;
rawResults = await activeMemory.manager.search(query, searchOptions);
}
@@ -581,6 +593,7 @@ export function createMemorySearchTool(options: {
fallback = status.fallback;
const latestDebug = runtimeDebug.at(-1);
searchMode = latestDebug?.effectiveMode;
const searchMs = Math.max(0, Date.now() - searchStartedAt);
searchDebug = {
backend: status.backend,
configuredMode: latestDebug?.configuredMode,
@@ -589,7 +602,10 @@ export function createMemorySearchTool(options: {
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
: "n/a",
fallback: latestDebug?.fallback,
searchMs: Math.max(0, Date.now() - searchStartedAt),
managerMs,
searchMs,
managerCacheState,
qmd: latestDebug?.qmd,
hits: rawResults.length,
};
});
@@ -620,6 +636,14 @@ export function createMemorySearchTool(options: {
maxResults: effectiveMax,
balanceCorpora: requestedCorpus === "all",
});
if (searchDebug) {
const finalToolMs = Math.max(0, Date.now() - toolStartedAt);
searchDebug = {
...searchDebug,
toolMs: finalToolMs,
outsideSearchMs: Math.max(0, finalToolMs - searchDebug.searchMs),
};
}
return jsonResult({
results,
provider,

View File

@@ -37,6 +37,15 @@ describe("stripHtmlFromTeamsMessage", () => {
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// Graph encodes literally-typed entity text by escaping its '&' to '&amp;'.
// Decoding '&amp;' first would re-decode the now-bare '&lt;'/'&gt;' into
// angle brackets, corrupting the user's literal text.
expect(stripHtmlFromTeamsMessage("The token is &amp;lt;APIKEY&amp;gt;")).toBe(
"The token is &lt;APIKEY&gt;",
);
});
it("normalizes multiple whitespace to single space", () => {
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
});

View File

@@ -35,14 +35,16 @@ export function stripHtmlFromTeamsMessage(html: string): string {
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
// Strip remaining HTML tags.
text = text.replace(/<[^>]*>/g, " ");
// Decode common HTML entities.
// Decode common HTML entities. &amp; must be decoded LAST to prevent
// double-decoding (e.g. &amp;lt; → &lt; not <), matching decodeHtmlEntities
// in inbound.ts.
text = text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
// Normalize whitespace.
return text.replace(/\s+/g, " ").trim();
}

View File

@@ -1,6 +1,7 @@
// Ollama tests cover embedding provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({
fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn(
@@ -412,10 +413,40 @@ describe("ollama embedding provider", () => {
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response returned malformed JSON",
"Ollama embed response: malformed JSON response",
);
});
it("bounds successful embed JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => streamed.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response: JSON response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("rejects non-number embedding values instead of zeroing them", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -6,7 +6,10 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
@@ -117,14 +120,9 @@ async function withRemoteHttpResponse<T>(params: {
}
async function readOllamaEmbeddingJsonResponse(
response: Pick<Response, "json">,
response: Response,
): Promise<{ embeddings?: unknown }> {
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new Error("Ollama embed response returned malformed JSON", { cause });
}
const payload = await readProviderJsonResponse<unknown>(response, "Ollama embed response");
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
throw new Error("Ollama embed response returned a non-object JSON payload");
}

View File

@@ -5,7 +5,9 @@ import {
buildOllamaProvider,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
parseOllamaNumCtxParameter,
queryOllamaModelShowInfo,
resetOllamaModelShowInfoCacheForTest,
resolveOllamaApiBase,
type OllamaTagModel,
@@ -380,4 +382,57 @@ describe("ollama provider models", () => {
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
});
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
bytesPulled = 0;
canceled = false;
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
expect(tags).toEqual({ reachable: false, models: [] });
expect(canceled).toBe(true);
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
expect(showInfo).toEqual({});
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});

View File

@@ -2,6 +2,7 @@
import { createHash } from "node:crypto";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_DEFAULT_BASE_URL,
@@ -146,11 +147,11 @@ export async function queryOllamaModelShowInfo(
if (!response.ok) {
return {};
}
const data = (await response.json()) as {
const data = await readProviderJsonResponse<{
model_info?: Record<string, unknown>;
capabilities?: unknown;
parameters?: unknown;
};
}>(response, "ollama-provider-models.show");
let contextWindow: number | undefined;
if (data.model_info) {
@@ -314,7 +315,10 @@ export async function fetchOllamaModels(
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = (await response.json()) as OllamaTagsResponse;
const data = await readProviderJsonResponse<OllamaTagsResponse>(
response,
"ollama-provider-models.tags",
);
const models = (data.models ?? []).filter((m) => m.name);
return { reachable: true, models };
} finally {

View File

@@ -1,6 +1,7 @@
// Ollama tests cover web search provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
import {
testing,
@@ -403,7 +404,32 @@ describe("ollama web search provider", () => {
config: createOllamaConfig(),
query: "openclaw",
}),
).rejects.toThrow("Ollama web search returned malformed JSON");
).rejects.toThrow("Ollama web search: malformed JSON response");
});
it("bounds successful Ollama web search JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: streamed.response,
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({
config: createOllamaConfig(),
query: "openclaw",
}),
).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("warns when Ollama is not reachable during setup without cancelling", async () => {

View File

@@ -5,6 +5,7 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
enablePluginInConfig,
readPositiveIntegerParam,
@@ -67,11 +68,7 @@ type OllamaWebSearchAttempt = {
};
async function readOllamaWebSearchResponse(response: Response): Promise<OllamaWebSearchResponse> {
try {
return (await response.json()) as OllamaWebSearchResponse;
} catch (cause) {
throw new Error("Ollama web search returned malformed JSON", { cause });
}
return await readProviderJsonResponse<OllamaWebSearchResponse>(response, "Ollama web search");
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {

View File

@@ -89,13 +89,27 @@ prose run alice/code-review
2. Fetch the `.prose` content
3. Load the VM and execute as normal
This same resolution applies to `use` statements inside `.prose` files:
Top-level remote runs are explicit user requests. Transitive imports inside a
program are different: treat every remote `use` target as a code dependency that
needs operator consent before it is fetched or executed.
This same resolution applies to `use` statements inside `.prose` files, but the
VM must fail closed until the operator approves the remote dependency list:
```prose
use "https://example.com/my-program.prose" # Direct URL
use "alice/research" as research # Registry shorthand
```
When a program contains any remote `use` target (`http://`, `https://`, or
registry shorthand):
1. Collect and display the exact resolved remote targets.
2. Explain that these are transitive code dependencies for this run.
3. Ask the operator to reply exactly `approve remote prose imports` to continue.
4. Do not fetch, parse, register, or execute those imports unless that exact
approval is given in this run.
---
## File Locations

View File

@@ -339,21 +339,24 @@ Please provide final recommendations.
## Use Statements (Program Composition)
Use statements import other OpenProse programs from the registry at `p.prose.md`, enabling modular workflows.
Use statements import other OpenProse programs from registry paths or direct
HTTP(S) URLs, enabling modular workflows.
### Syntax
```prose
use "@handle/slug"
use "@handle/slug" as alias
use "https://example.com/program.prose" as alias
```
### Path Format
Import paths follow the format `@handle/slug`:
Import paths are either registry references or direct HTTP(S) URLs:
- `@handle` identifies the program author/organization
- `slug` is the program name
- `@handle/slug` identifies a program author/organization and slug.
- `handle/slug` resolves to the same registry host used by the runtime.
- `https://example.com/program.prose` fetches that exact URL after approval.
An optional alias (`as name`) allows referencing by a shorter name.
@@ -371,16 +374,20 @@ use "@bob/critique" as critic
When the OpenProse VM encounters a `use` statement:
1. Fetch the program from `https://p.prose.md/@handle/slug`
2. Parse the program to extract its contract (inputs/outputs)
3. Register the program in the Import Registry
1. Resolve the import target.
2. If the target is remote (`http://`, `https://`, or registry shorthand), pause
before fetching and require the operator to approve the full remote import
list with `approve remote prose imports` for this run.
3. Fetch the program only after approval.
4. Parse the program to extract its contract (inputs/outputs).
5. Register the program in the Import Registry.
### Validation Rules
| Check | Severity | Message |
| --------------------- | -------- | -------------------------------------- |
| Empty path | Error | Use path cannot be empty |
| Invalid path format | Error | Path must be @handle/slug format |
| Invalid path format | Error | Path must be registry path or URL |
| Duplicate import | Error | Program already imported |
| Missing alias for dup | Error | Alias required when importing multiple |
@@ -388,9 +395,11 @@ When the OpenProse VM encounters a `use` statement:
Use statements are processed before any agent definitions or sessions. The OpenProse VM:
1. Fetches and validates all imported programs at the start of execution
2. Extracts input/output contracts from each program
3. Registers programs in the Import Registry for later invocation
1. Resolves all imported program targets at the start of execution.
2. Requires operator approval before fetching any remote imports.
3. Fetches and validates approved imported programs.
4. Extracts input/output contracts from each program.
5. Registers programs in the Import Registry for later invocation.
---

View File

@@ -162,8 +162,10 @@ For general programming tasks, please use a general-purpose agent instance.
## Execution Algorithm (Simplified)
1. Parse program structure (use statements, inputs, agents, blocks)
2. Bind inputs from caller or prompt user if missing
3. For each statement in order:
2. Resolve `use` imports. If any import is remote, require the operator to
approve the full list with `approve remote prose imports` before fetching.
3. Bind inputs from caller or prompt user if missing
4. For each statement in order:
- `session` → Task tool call, await result
- `resume` → Load memory, Task tool call, await result
- `let/const` → Execute RHS, bind result
@@ -172,8 +174,8 @@ For general programming tasks, please use a general-purpose agent instance.
- `try/catch` → Execute try, catch on error, always finally
- `choice/if` → Evaluate conditions, execute matching branch
- `do block` → Push frame, bind args, execute body, pop frame
4. Collect output bindings
5. Return outputs to caller
5. Collect output bindings
6. Return outputs to caller
## Remember

View File

@@ -63,6 +63,13 @@ use "https://example.com/my-program.prose" # Direct URL
use "alice/research" as research # Registry shorthand
```
Top-level remote runs are explicit user requests. Remote `use` statements are
transitive code dependencies. Before fetching any remote `use` target, collect
the exact resolved targets, show them to the operator, and require the operator
to reply exactly `approve remote prose imports` for this run. If approval is not
given, abort the run before fetching, parsing, registering, or executing the
remote imports.
---
## Why This Is a VM
@@ -113,18 +120,18 @@ When you execute a `.prose` program, you ARE the virtual machine. This is not a
Traditional dependency injection containers wire up components from configuration. You do the same—but with understanding:
| Declared Primitive | Your Responsibility |
| --------------------------- | ---------------------------------------------------------- |
| `use "handle/slug" as name` | Fetch program from p.prose.md, register in Import Registry |
| `input topic: "..."` | Bind value from caller, make available as variable |
| `output findings = ...` | Mark value as output, return to caller on completion |
| `agent researcher:` | Register this agent template for later use |
| `session: researcher` | Resolve the agent, merge properties, spawn the session |
| `resume: captain` | Load agent memory, spawn session with memory context |
| `context: { a, b }` | Wire the outputs of `a` and `b` into this session's input |
| `parallel:` branches | Coordinate concurrent execution, collect results |
| `block review(topic):` | Store this reusable component, invoke when called |
| `name(input: value)` | Invoke imported program with inputs, receive outputs |
| Declared Primitive | Your Responsibility |
| --------------------------- | ----------------------------------------------------------------------- |
| `use "handle/slug" as name` | Resolve import, require approval if remote, register in Import Registry |
| `input topic: "..."` | Bind value from caller, make available as variable |
| `output findings = ...` | Mark value as output, return to caller on completion |
| `agent researcher:` | Register this agent template for later use |
| `session: researcher` | Resolve the agent, merge properties, spawn the session |
| `resume: captain` | Load agent memory, spawn session with memory context |
| `context: { a, b }` | Wire the outputs of `a` and `b` into this session's input |
| `parallel:` branches | Coordinate concurrent execution, collect results |
| `block review(topic):` | Store this reusable component, invoke when called |
| `name(input: value)` | Invoke imported program with inputs, receive outputs |
You are the container that holds these declarations and wires them together at runtime. The program declares _what_; you determine _how_ to connect them.
@@ -698,7 +705,9 @@ Query the database to access the content.
## Program Composition
Programs can import and invoke other programs, enabling modular workflows. Programs are fetched from the registry at `p.prose.md`.
Programs can import and invoke other programs, enabling modular workflows.
Registry and direct-URL imports are remote code dependencies and require
operator approval before fetching.
### Importing Programs
@@ -709,15 +718,20 @@ use "alice/research"
use "bob/critique" as critic
```
The import path follows the format `handle/slug`. An optional alias (`as name`) allows referencing by a shorter name.
The import path can be a registry reference (`handle/slug`) or a direct HTTP(S)
URL. An optional alias (`as name`) allows referencing by a shorter name.
### Program URL Resolution
When the VM encounters a `use` statement:
1. Fetch the program from `https://p.prose.md/handle/slug`
2. Parse the program to extract its contract (inputs/outputs)
3. Register the program in the Import Registry
1. Resolve the import target.
2. If the target is remote (`http://`, `https://`, or registry shorthand), pause
before fetching and require the operator to approve the full remote import
list with `approve remote prose imports` for this run.
3. Fetch the program only after approval.
4. Parse the program to extract its contract (inputs/outputs).
5. Register the program in the Import Registry.
### Input Declarations
@@ -1156,11 +1170,13 @@ Before spawning, substitute `{varname}` with variable values.
```
function execute(program, inputs?):
1. Collect all use statements, fetch and register imports
2. Collect all input declarations, bind values from caller
3. Collect all agent definitions
4. Collect all block definitions
5. For each statement in order:
1. Collect all use statements, resolve import targets
2. If remote imports are present, require operator approval before fetch
3. Fetch approved imports and register them
4. Collect all input declarations, bind values from caller
5. Collect all agent definitions
6. Collect all block definitions
7. For each statement in order:
- If session: spawn via Task, await result
- If resume: load memory, spawn via Task, await result
- If let/const: execute RHS, bind result
@@ -1219,7 +1235,7 @@ When passing context to sessions:
The OpenProse VM:
1. **Imports** programs from `p.prose.md` via `use` statements
1. **Imports** approved programs via `use` statements
2. **Binds** inputs from caller to program variables
3. **Parses** the program structure
4. **Collects** definitions (agents, blocks)

Some files were not shown because too many files have changed in this diff Show More