Compare commits

..

84 Commits

Author SHA1 Message Date
Tak Hoffman
ed4dbe63a5 fix web search fallback explicitness 2026-04-07 07:18:10 -05:00
Tak Hoffman
e8b7694f05 fix infer auth logout persistence 2026-04-07 07:00:30 -05:00
Tak Hoffman
db65818d0b validate explicit web search providers 2026-04-07 06:25:24 -05:00
Tak Hoffman
00c0e9fb36 fix tts runtime facade export 2026-04-07 06:21:47 -05:00
Tak Hoffman
4ba0ce3ed1 flatten infer media commands 2026-04-07 06:10:09 -05:00
Tak Hoffman
d18d56f040 refresh infer branch onto latest main 2026-04-07 05:57:11 -05:00
Vincent Koc
1e5b026e61 perf(plugins): abort failed boundary compile siblings 2026-04-07 11:47:10 +01:00
Peter Steinberger
f13542f211 test: fix manifest registry candidate fixtures 2026-04-07 11:43:10 +01:00
Vincent Koc
f54188f600 fix(plugins): abort sibling boundary prep steps 2026-04-07 11:42:45 +01:00
Vincent Koc
aa61b508d1 perf(plugin-sdk): slim provider contract enable path 2026-04-07 11:42:24 +01:00
Peter Steinberger
d6b634bc30 test: harden gateway talk and config drift coverage 2026-04-07 11:41:02 +01:00
Peter Steinberger
a20d96ae31 test: stabilize isolated runtime and config suites 2026-04-07 11:41:02 +01:00
Peter Steinberger
8be79a09b8 build: align plugin sdk boundary exports 2026-04-07 11:41:02 +01:00
Vincent Koc
0ca8eb40c1 refactor(plugins): stream boundary prep step output 2026-04-07 11:38:04 +01:00
Peter Steinberger
525e78e3d9 test: split message command coverage 2026-04-07 11:35:59 +01:00
Peter Steinberger
ce18c3e9e7 test: speed up auto-reply registry tests 2026-04-07 11:35:59 +01:00
Peter Steinberger
be3b7cf875 test: speed up whatsapp inbound media test 2026-04-07 11:35:59 +01:00
Peter Steinberger
5489bff7c3 test: speed up chutes model tests 2026-04-07 11:35:58 +01:00
Vincent Koc
1604b4a304 test(plugins): lock boundary path override inventory 2026-04-07 11:34:45 +01:00
Vincent Koc
9a4e35a24f perf(secrets): fast-path bundled channel contract loads 2026-04-07 11:34:09 +01:00
Vincent Koc
cd54f20fe2 perf(plugins): parallelize boundary artifact prep 2026-04-07 11:32:25 +01:00
Vincent Koc
a8e46e7048 fix(plugins): scrub canary artifacts for all opt-in packages 2026-04-07 11:26:34 +01:00
Vincent Koc
5613f5a834 perf(secrets): narrow legacy web search compat providers 2026-04-07 11:25:19 +01:00
Bob
f6124f3e17 ACP: harden Discord recovery and reset flow (#62132)
* ACP: harden Discord recovery and reset flow

* CI: harden bundled vitest excludes

* ACP: fix Claude launch and reset recovery

* Discord: use follow-up replies after slash defer

* ACP: route bound resets through gateway service

* ACP: unify bound reset authority

* ACPX: update OpenClaw branch to 0.5.2

* ACP: fix rebuilt branch replay fallout

* ACP: fix CI regressions after ACPX 0.5.2 update

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-04-07 12:23:50 +02:00
Peter Steinberger
4fa7931b1b build: sync generated gateway protocol models 2026-04-07 11:22:07 +01:00
Vincent Koc
29732c1459 test(plugins): lock xai boundary path drift 2026-04-07 11:21:04 +01:00
Peter Steinberger
1fdb013599 refactor: dedupe routing lowercase helpers 2026-04-07 11:18:18 +01:00
Peter Steinberger
36938bccb5 refactor: dedupe channel lowercase helpers 2026-04-07 11:18:18 +01:00
Peter Steinberger
5de04bc1d5 refactor: dedupe extension lowercase query helpers 2026-04-07 11:18:18 +01:00
Peter Steinberger
967ecddfed refactor: dedupe extension lower readers 2026-04-07 11:18:18 +01:00
Peter Steinberger
6bd6f4d27c refactor: dedupe shared lowercase helpers 2026-04-07 11:18:18 +01:00
Peter Steinberger
4dc16e1567 refactor: dedupe lowercase normalizer readers 2026-04-07 11:18:18 +01:00
Peter Steinberger
af1cf77b16 refactor: dedupe extension lowercase readers 2026-04-07 11:18:18 +01:00
Peter Steinberger
fbdb20ffd3 refactor: dedupe reply lowercase helpers 2026-04-07 11:18:18 +01:00
Peter Steinberger
3139d2007e refactor: dedupe lowercase empty-string readers 2026-04-07 11:18:18 +01:00
Peter Steinberger
55f07e0381 refactor: dedupe shared string normalizers 2026-04-07 11:18:18 +01:00
Peter Steinberger
0bbd70ac79 style: normalize line monitor lifecycle test formatting 2026-04-07 11:18:09 +01:00
Peter Steinberger
9437c24764 test: speed up line monitor lifecycle coverage 2026-04-07 11:18:09 +01:00
Peter Steinberger
1baff9c64c test: speed up irc setup lifecycle coverage 2026-04-07 11:18:09 +01:00
Peter Steinberger
874ca3d691 test: split media understanding helper coverage 2026-04-07 11:18:09 +01:00
Peter Steinberger
1395650d95 test: fix huggingface discovery fixture 2026-04-07 11:16:59 +01:00
Vincent Koc
0b04d27beb fix(plugins): clear stale boundary canaries before compile 2026-04-07 11:14:09 +01:00
Vincent Koc
881f41d4a1 fix(plugins): clean package boundary canary artifacts 2026-04-07 11:10:16 +01:00
Vincent Koc
1b20303c0c perf(plugins): cache package boundary dts 2026-04-07 11:07:08 +01:00
Peter Steinberger
1e5f5fa319 perf(auto-reply): trim plugin install and directive tests 2026-04-07 11:06:23 +01:00
Nimrod Gutman
d008e2d015 fix(exec): align node shell allowlist wrappers (#62401)
* fix(exec): align node shell allowlist wrappers

* fix: align node shell allowlist wrappers (#62401) (thanks @ngutman)
2026-04-07 13:05:57 +03:00
Vignesh Natarajan
b6a806d67b chore(test): align staged runtime deps test typing 2026-04-07 03:05:46 -07:00
Peter Steinberger
56b0714004 Tests: fix gateway reconnect and mocks 2026-04-07 11:02:54 +01:00
Vignesh Natarajan
3fbb229d04 chore(ui): guard dreaming toggle for strict plugin schemas 2026-04-07 03:01:25 -07:00
Peter Steinberger
ec708f44df docs: update changelog for ollama discovery 2026-04-07 10:59:00 +01:00
Vincent Koc
dbcb1f06ec fix(test): suppress vitest plugin timing noise 2026-04-07 10:54:20 +01:00
Vincent Koc
90e8bef253 perf(secrets): skip no-op write runtime preflight 2026-04-07 10:52:08 +01:00
Vincent Koc
f7957d3bb7 fix(plugins): restore shared boundary sdk paths 2026-04-07 10:48:56 +01:00
Vignesh Natarajan
b21dd9c635 Tests: stabilize dream diary case assertion (#62275) 2026-04-07 02:47:46 -07:00
Vignesh Natarajan
733063e31c fix: slot-aware dreaming config paths (#62275) (thanks @SnowSky1) 2026-04-07 02:47:46 -07:00
Vignesh Natarajan
d84ac5b1eb Dreaming UI: use slot-aware configured state 2026-04-07 02:47:46 -07:00
sky
9dda94c0f7 fix(memory): respect memory slot in dreaming config 2026-04-07 02:47:46 -07:00
Peter Steinberger
24d4acb274 perf(test): parallelize extension boundary compile 2026-04-07 10:43:05 +01:00
Vincent Koc
b4d0d6fcc9 perf(secrets): narrow dry-run auth store preflight 2026-04-07 10:39:54 +01:00
Peter Steinberger
2b5f663c9c fix(ci): prepare plugin sdk boundary dts before lint 2026-04-07 10:37:39 +01:00
Peter Steinberger
67e6f88e42 fix: restore provider public artifact types 2026-04-07 10:37:39 +01:00
Peter Steinberger
a5efc9a6c9 refactor: dedupe acp reply lowercase helpers 2026-04-07 10:37:39 +01:00
Peter Steinberger
74ea9de6f2 refactor: dedupe reply lowercase helpers 2026-04-07 10:37:39 +01:00
Peter Steinberger
434d56a948 refactor: dedupe lowercase helper readers 2026-04-07 10:37:39 +01:00
Peter Steinberger
f54a57b80a refactor: dedupe lowercase string helpers 2026-04-07 10:37:39 +01:00
Peter Steinberger
f1bdfca1ed refactor: dedupe reply gateway helpers 2026-04-07 10:37:39 +01:00
Peter Steinberger
cb29ecc100 refactor: dedupe channel helper readers 2026-04-07 10:37:39 +01:00
Peter Steinberger
255abc57b9 refactor: dedupe thread id normalizers 2026-04-07 10:37:39 +01:00
Peter Steinberger
edfc8eb91a refactor: dedupe primary string helpers 2026-04-07 10:37:39 +01:00
Peter Steinberger
dd3e86d35b refactor: dedupe provider registry normalizers 2026-04-07 10:37:38 +01:00
Vincent Koc
bf040219e4 perf(plugins): cache extension boundary type checks 2026-04-07 10:36:19 +01:00
Peter Steinberger
4d4dbe8e15 test: share live probes with acp bind 2026-04-07 10:35:24 +01:00
Peter Steinberger
c2f9de3935 feat: unify live cli backend probes 2026-04-07 10:35:24 +01:00
Peter Steinberger
dbc7710938 Tests: fix gateway reconnect and boundary drift 2026-04-07 17:30:37 +08:00
Vincent Koc
5ae27dfb5a perf(secrets): skip idle plugin origin discovery 2026-04-07 10:27:02 +01:00
Peter Steinberger
e3cb19d162 test(boundary): unify package sdk type paths 2026-04-07 10:26:35 +01:00
Peter Steinberger
524951e124 fix(ci): route qa-lab web imports through package barrels 2026-04-07 10:24:02 +01:00
Vincent Koc
16877efba3 ci(plugins): enforce extension package boundary checks 2026-04-07 10:22:12 +01:00
Peter Steinberger
9db1a7acf0 fix(ci): restore array-safe record coercion 2026-04-07 10:17:40 +01:00
Vincent Koc
4329d94de3 fix(plugins): stabilize package boundary tsc checks 2026-04-07 10:15:34 +01:00
Peter Steinberger
34c78d3ba4 fix(ci): restore control-ui and provider policy checks 2026-04-07 10:12:01 +01:00
Peter Steinberger
991e25b880 Tests: move more leaf tests to unit fast 2026-04-07 10:10:39 +01:00
Peter Steinberger
f510576959 Tests: fix provider artifact typing 2026-04-07 10:07:06 +01:00
Vincent Koc
ea5faa9b39 perf(secrets): lazy-load apply test runtime 2026-04-07 10:06:23 +01:00
390 changed files with 10231 additions and 5213 deletions

View File

@@ -753,6 +753,11 @@ jobs:
continue-on-error: true
run: pnpm run lint:extensions:bundled
- name: Run extension package boundary TypeScript check
id: extension_package_boundary_tsc
continue-on-error: true
run: pnpm run test:extensions:package-boundary
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
@@ -797,6 +802,7 @@ jobs:
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
@@ -820,6 +826,7 @@ jobs:
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
@@ -23,20 +24,12 @@ Docs: https://docs.openclaw.ai
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
- Memory/wiki: add task-backed `wiki import` with automatic local-file and markdown-vault detection so existing note stores can be backfilled into source pages with shared task progress instead of ad hoc one-off ingest flows. Thanks @vincentkoc.
- Memory/wiki: keep imported Obsidian and Logseq notes readable by preserving markdown note bodies plus imported tags, aliases, and link hints instead of flattening every vault note into a fenced text blob. Thanks @vincentkoc.
- Memory/wiki: use imported vault tags, aliases, and link hints in the compiled digest and wiki search ranking so imported Obsidian and Logseq notes are easier to recall by their original note metadata. Thanks @vincentkoc.
- Memory/wiki: use imported vault aliases and link hints when building `## Related` backlinks so imported Obsidian and Logseq notes can reconnect their note graph after import. Thanks @vincentkoc.
- Memory/wiki: let `wiki_get` and metadata updates resolve imported vault titles and aliases directly, so imported notes stay addressable by their original note names instead of only generated paths. Thanks @vincentkoc.
- Memory/wiki: upgrade `reports/import-review.md` to flag duplicate imported titles and aliases plus obviously low-signal notes, so large vault imports are easier to triage before promotion or synthesis work. Thanks @vincentkoc.
- Memory/wiki: add duplicate-body clustering to `reports/import-review.md` so large vault imports can surface copied or renamed notes even when titles and aliases differ. Thanks @vincentkoc.
- Memory/wiki: preserve imported markdown vault relative paths in digest, lookup, and related-link reconstruction so imported note identity survives search and `wiki_get`. Thanks @vincentkoc.
- Memory/wiki: auto-detect and import ChatGPT export JSON files as conversation source pages instead of misclassifying them as generic local files. Thanks @vincentkoc.
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
### Fixes
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
- CLI/infer: keep provider-backed infer behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
@@ -46,12 +39,8 @@ Docs: https://docs.openclaw.ai
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Memory/wiki: follow `current_node` when importing ChatGPT export mapping trees so imported conversation transcripts stop pulling in stale alternate branches. Thanks @vincentkoc.
- Memory/wiki: extract readable text from object-shaped ChatGPT export message parts so imported conversation transcripts stop dropping rich content blocks. Thanks @vincentkoc.
- Memory/wiki: preserve conversation turn order for ChatGPT imports when timestamps are missing or tied, so imported transcripts stop scrambling equal-time messages and current-branch lineage. Thanks @vincentkoc.
- Memory/wiki: skip hidden and tool-role ChatGPT export messages during import so conversation source pages stop filling up with export-only scaffolding. Thanks @vincentkoc.
- Memory/wiki: skip ChatGPT export conversations that end up with no readable visible turns, so imports stop generating empty placeholder source pages from hidden/tool-only records. Thanks @vincentkoc.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Nodes/exec approvals: keep `host=node` POSIX transport shell wrappers (`/bin/sh -lc ...`) aligned with inner-command allowlist analysis so allowlisted scripts stop prompting unnecessarily, while Windows `cmd.exe` wrapper runs stay approval-gated. (#62401) Thanks @ngutman.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
@@ -65,6 +54,7 @@ Docs: https://docs.openclaw.ai
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Providers/Ollama: stop warning that Ollama could not be reached when discovery only sees empty default local stubs, while still keeping real explicit Ollama overrides loud when the endpoint is unreachable.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
@@ -262,6 +252,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
- Memory/dreaming: make Dreams config reads and writes respect the selected memory slot plugin (including `doctor.memory.status` and Control UI fallback state) instead of always targeting `memory-core`. (#62275) Thanks @SnowSky1.
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.

View File

@@ -537,6 +537,8 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
@@ -566,6 +568,8 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -594,6 +598,8 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
@@ -624,6 +630,8 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"

View File

@@ -537,6 +537,8 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
@@ -566,6 +568,8 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -594,6 +598,8 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
@@ -624,6 +630,8 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"

View File

@@ -1,2 +1,2 @@
3d483bffbe5abb831df3b1efdf40e1ae0d22d644853a7629ecdaa6d535386ee6 plugin-sdk-api-baseline.json
eebeff7cc3ca490d3cae268ea97c5968f37f50fe1a9c7eabeeab85a4ae66a9d9 plugin-sdk-api-baseline.jsonl
bcf997afb562b69552d0bf772ccad85b48df14cc3f314fdd5265644702fdfd2d plugin-sdk-api-baseline.json
fcef1106262e6d0f53d67d1f1e968b34cd49ed45c89972364fe6c7d9ffcf1f5b plugin-sdk-api-baseline.jsonl

119
docs/cli/capability.md Normal file
View File

@@ -0,0 +1,119 @@
---
summary: "Infer-first CLI for provider-backed model, image, audio, TTS, video, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw infer` commands
- Designing stable headless capability automation
title: "Inference CLI"
---
# Inference CLI
`openclaw infer` is the canonical headless surface for provider-backed inference workflows.
`openclaw capability` remains supported as a fallback alias for compatibility.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Command tree
```text
openclaw infer
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
embedding
create
providers
```
## Transport
Supported transport flags:
- `--local`
- `--gateway`
Default transport is implicit auto at the command-family level:
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
Examples:
```bash
openclaw infer model run --prompt "hello" --json
openclaw infer image generate --prompt "friendly lobster" --json
openclaw infer tts status --json
openclaw infer embedding create --text "hello world" --json
```
## JSON output
Capability commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.

View File

@@ -35,6 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`infer`](/cli/capability)
- [`memory`](/cli/memory)
- [`directory`](/cli/directory)
- [`nodes`](/cli/nodes)
@@ -248,6 +249,16 @@ openclaw [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
infer (alias: capability)
list
inspect
model run|list|inspect|providers|auth login|logout|status
image generate|edit|describe|describe-many|providers
audio transcribe|providers
tts convert|voices|providers|status|enable|disable|set-provider
video generate|describe|providers
web search|fetch|providers
embedding create|providers
auth add|login|login-github-copilot|setup-token|paste-token
auth order get|set|clear
sandbox

View File

@@ -360,7 +360,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- or `npm install -g @google/gemini-cli`
- Enable: `openclaw plugins enable google`
- Login: `openclaw models auth login --provider google-gemini-cli --set-default`
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
- Default model: `google-gemini-cli/gemini-3-flash-preview`
- Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores
tokens in auth profiles on the gateway host.
- If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host.

View File

@@ -214,8 +214,10 @@ The bundled OpenAI plugin also registers a default for `codex-cli`:
The bundled Google plugin also registers a default for `google-gemini-cli`:
- `command: "gemini"`
- `args: ["--prompt", "--output-format", "json"]`
- `resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"]`
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
- `imageArg: "@"`
- `imagePathScope: "workspace"`
- `modelArg: "--model"`
- `sessionMode: "existing"`
- `sessionIdFields: ["session_id", "sessionId"]`
@@ -251,8 +253,9 @@ opt into a generated MCP config overlay with `bundleMcp: true`.
Current bundled behavior:
- `codex-cli`: no bundle MCP overlay
- `google-gemini-cli`: no bundle MCP overlay
- `claude-cli`: generated strict MCP config file
- `codex-cli`: inline config overrides for `mcp_servers`
- `google-gemini-cli`: generated Gemini system settings file
When bundle MCP is enabled, OpenClaw:
@@ -260,8 +263,8 @@ When bundle MCP is enabled, OpenClaw:
- authenticates the bridge with a per-session token (`OPENCLAW_MCP_TOKEN`)
- scopes tool access to the current session, account, and channel context
- loads enabled bundle-MCP servers for the current workspace
- merges them with any existing backend `--mcp-config`
- rewrites the CLI args to pass `--strict-mcp-config --mcp-config <generated-file>`
- merges them with any existing backend MCP config/settings shape
- rewrites the launch config using the backend-owned integration mode from the owning extension
If no MCP servers are enabled, OpenClaw still injects a strict config when a
backend opts into bundle MCP so background runs stay isolated.

View File

@@ -701,7 +701,7 @@ for usage/billing and raise limits as needed.
- npm: `npm install -g @google/gemini-cli`
2. Enable the plugin: `openclaw plugins enable google`
3. Login: `openclaw models auth login --provider google-gemini-cli --set-default`
4. Default model after login: `google-gemini-cli/gemini-3.1-pro-preview`
4. Default model after login: `google-gemini-cli/gemini-3-flash-preview`
5. If requests fail, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).

View File

@@ -300,6 +300,7 @@ Notes:
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
## Live: ACP bind smoke (`/acp spawn ... --bind here`)

View File

@@ -52,7 +52,7 @@ An alternative provider `google-gemini-cli` uses PKCE OAuth instead of an API
key. This is an unofficial integration; some users report account
restrictions. Use at your own risk.
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
- Default model: `google-gemini-cli/gemini-3-flash-preview`
- Alias: `gemini-cli`
- Install prerequisite: local Gemini CLI available as `gemini`
- Homebrew: `brew install gemini-cli`

View File

@@ -54,7 +54,7 @@ model as `provider/model`.
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3.1-pro-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

View File

@@ -4,7 +4,7 @@
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {
"acpx": "0.5.1"
"acpx": "0.5.2"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -45,7 +45,11 @@ declare module "acpx/runtime" {
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: {
handle: AcpRuntimeHandle;
reason?: string;
discardPersistentState?: boolean;
}): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;

View File

@@ -1,74 +1,37 @@
import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
import type { AcpSessionStore } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpRuntime } from "../runtime-api.js";
import { AcpxRuntime } from "./runtime.js";
const mocks = vi.hoisted(() => {
const state = {
capturedStore: undefined as AcpSessionStore | undefined,
};
class MockAcpxRuntime {
constructor(options: AcpRuntimeOptions) {
state.capturedStore = options.sessionStore;
}
isHealthy() {
return true;
}
async probeAvailability() {}
async doctor() {
return { ok: true, message: "ok" };
}
async ensureSession() {
return {
sessionKey: "agent:codex:acp:binding:test",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:binding:test",
} satisfies AcpRuntimeHandle;
}
async *runTurn() {}
getCapabilities() {
return { controls: [] };
}
async getStatus() {
return {};
}
async setMode() {}
async setConfigOption() {}
async cancel() {}
async close() {}
}
function makeRuntime(baseStore: AcpSessionStore): {
runtime: AcpxRuntime;
wrappedStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
delegate: { close: AcpRuntime["close"] };
} {
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
return {
state,
MockAcpxRuntime,
runtime,
wrappedStore: (
runtime as unknown as {
sessionStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
}
).sessionStore,
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
};
});
vi.mock("acpx/runtime", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: mocks.MockAcpxRuntime,
createAcpRuntime: vi.fn(),
createAgentRegistry: vi.fn(),
createFileSessionStore: vi.fn(),
decodeAcpxRuntimeHandleState: vi.fn(),
encodeAcpxRuntimeHandleState: vi.fn(),
}));
import { AcpxRuntime } from "./runtime.js";
}
describe("AcpxRuntime fresh reset wrapper", () => {
beforeEach(() => {
mocks.state.capturedStore = undefined;
vi.restoreAllMocks();
});
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
@@ -77,20 +40,9 @@ describe("AcpxRuntime fresh reset wrapper", () => {
save: vi.fn(async () => {}),
};
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
const { runtime, wrappedStore } = makeRuntime(baseStore);
const wrappedStore = mocks.state.capturedStore;
expect(wrappedStore).toBeDefined();
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore.load).toHaveBeenCalledTimes(1);
@@ -99,17 +51,17 @@ describe("AcpxRuntime fresh reset wrapper", () => {
sessionKey: "agent:codex:acp:binding:test",
});
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).toHaveBeenCalledTimes(1);
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).toHaveBeenCalledTimes(1);
await wrappedStore?.save({
await wrappedStore.save({
acpxRecordId: "fresh-record",
name: "agent:codex:acp:binding:test",
} as never);
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore.load).toHaveBeenCalledTimes(2);
@@ -121,18 +73,8 @@ describe("AcpxRuntime fresh reset wrapper", () => {
save: vi.fn(async () => {}),
};
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
const wrappedStore = mocks.state.capturedStore;
expect(wrappedStore).toBeDefined();
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
await runtime.close({
handle: {
@@ -144,7 +86,16 @@ describe("AcpxRuntime fresh reset wrapper", () => {
discardPersistentState: true,
});
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(close).toHaveBeenCalledWith({
handle: {
sessionKey: "agent:codex:acp:binding:test",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:binding:test",
},
reason: "new-in-place-reset",
discardPersistentState: true,
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).not.toHaveBeenCalled();
});
});

View File

@@ -131,6 +131,7 @@ export class AcpxRuntime implements AcpxRuntimeLike {
.close({
handle: input.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState,
})
.then(() => {
if (input.discardPersistentState) {

View File

@@ -19,12 +19,14 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
liveTest: {
defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
defaultImageProbe: true,
defaultMcpProbe: true,
docker: {
npmPackage: "@anthropic-ai/claude-code",
binaryName: "claude",
},
},
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "claude",
args: [

View File

@@ -1,4 +1,5 @@
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`;
@@ -92,7 +93,7 @@ const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
const CLAUDE_SAFE_SETTING_SOURCES = "user";
export function isClaudeCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID;
return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {

View File

@@ -18,6 +18,9 @@
"contracts": {
"webSearchProviders": ["brave"]
},
"configContracts": {
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
@@ -26,11 +27,11 @@ export function resolveBrowserControlAuth(
}
function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
const nodeEnv = normalizeLowercaseStringOrEmpty(env.NODE_ENV);
if (nodeEnv === "test") {
return false;
}
const vitest = (env.VITEST ?? "").trim().toLowerCase();
const vitest = normalizeLowercaseStringOrEmpty(env.VITEST);
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
return false;
}

View File

@@ -1,4 +1,5 @@
import type { NextFunction, Request, Response } from "express";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { isLoopbackHost } from "../gateway/net.js";
function firstHeader(value: string | string[] | undefined): string {
@@ -35,7 +36,7 @@ export function shouldRejectBrowserMutation(params: {
// Strong signal when present: browser says this is cross-site.
// Avoid being overly clever with "same-site" since localhost vs 127.0.0.1 may differ.
const secFetchSite = (params.secFetchSite ?? "").trim().toLowerCase();
const secFetchSite = normalizeLowercaseStringOrEmpty(params.secFetchSite);
if (secFetchSite === "cross-site") {
return true;
}

View File

@@ -1,3 +1,4 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { browserCloseTab } from "./client.js";
export type TrackedSessionBrowserTab = {
@@ -11,7 +12,7 @@ export type TrackedSessionBrowserTab = {
const trackedTabsBySession = new Map<string, Map<string, TrackedSessionBrowserTab>>();
function normalizeSessionKey(raw: string): string {
return raw.trim().toLowerCase();
return normalizeOptionalLowercaseString(raw) ?? "";
}
function normalizeTargetId(raw: string): string {
@@ -19,11 +20,7 @@ function normalizeTargetId(raw: string): string {
}
function normalizeProfile(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
return normalizeOptionalLowercaseString(raw);
}
function normalizeBaseUrl(raw?: string): string | undefined {

View File

@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type ChutesModelsModule = typeof import("./models.js");
let chutesModels: ChutesModelsModule;
import {
buildChutesModelDefinition,
CHUTES_MODEL_CATALOG,
clearChutesModelCacheForTests,
discoverChutesModels,
} from "./models.js";
async function withLiveChutesDiscovery<T>(
fetchMock: ReturnType<typeof vi.fn>,
@@ -44,14 +46,13 @@ function createAuthEchoFetchMock() {
}
describe("chutes-models", () => {
beforeEach(async () => {
vi.resetModules();
chutesModels = await import("./models.js");
beforeEach(() => {
clearChutesModelCacheForTests();
});
it("buildChutesModelDefinition returns config with required fields", () => {
const entry = chutesModels.CHUTES_MODEL_CATALOG[0];
const def = chutesModels.buildChutesModelDefinition(entry);
const entry = CHUTES_MODEL_CATALOG[0];
const def = buildChutesModelDefinition(entry);
expect(def.id).toBe(entry.id);
expect(def.name).toBe(entry.name);
expect(def.reasoning).toBe(entry.reasoning);
@@ -63,14 +64,14 @@ describe("chutes-models", () => {
});
it("discoverChutesModels returns static catalog when accessToken is empty", async () => {
const models = await chutesModels.discoverChutesModels("");
expect(models).toHaveLength(chutesModels.CHUTES_MODEL_CATALOG.length);
expect(models.map((m) => m.id)).toEqual(chutesModels.CHUTES_MODEL_CATALOG.map((m) => m.id));
const models = await discoverChutesModels("");
expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length);
expect(models.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
});
it("discoverChutesModels returns static catalog in test env by default", async () => {
const models = await chutesModels.discoverChutesModels("test-token");
expect(models).toHaveLength(chutesModels.CHUTES_MODEL_CATALOG.length);
const models = await discoverChutesModels("test-token");
expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length);
expect(models[0]?.id).toBe("Qwen/Qwen3-32B");
});
@@ -93,7 +94,7 @@ describe("chutes-models", () => {
}),
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await chutesModels.discoverChutesModels("test-token-real-fetch");
const models = await discoverChutesModels("test-token-real-fetch");
expect(models.length).toBeGreaterThan(0);
if (models.length === 3) {
expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE");
@@ -146,7 +147,7 @@ describe("chutes-models", () => {
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await chutesModels.discoverChutesModels("test-token-error");
const models = await discoverChutesModels("test-token-error");
expect(models.length).toBeGreaterThan(0);
expect(mockFetch).toHaveBeenCalled();
});
@@ -159,10 +160,10 @@ describe("chutes-models", () => {
});
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await chutesModels.discoverChutesModels("chutes-fallback-token");
const second = await chutesModels.discoverChutesModels("chutes-fallback-token");
expect(first.map((m) => m.id)).toEqual(chutesModels.CHUTES_MODEL_CATALOG.map((m) => m.id));
expect(second.map((m) => m.id)).toEqual(chutesModels.CHUTES_MODEL_CATALOG.map((m) => m.id));
const first = await discoverChutesModels("chutes-fallback-token");
const second = await discoverChutesModels("chutes-fallback-token");
expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
@@ -196,9 +197,9 @@ describe("chutes-models", () => {
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
const modelsA = await chutesModels.discoverChutesModels("chutes-token-a");
const modelsB = await chutesModels.discoverChutesModels("chutes-token-b");
const modelsASecond = await chutesModels.discoverChutesModels("chutes-token-a");
const modelsA = await discoverChutesModels("chutes-token-a");
const modelsB = await discoverChutesModels("chutes-token-b");
const modelsASecond = await discoverChutesModels("chutes-token-a");
expect(modelsA[0]?.id).toBe("private/model-a");
expect(modelsB[0]?.id).toBe("private/model-b");
expect(modelsASecond[0]?.id).toBe("private/model-a");
@@ -211,10 +212,10 @@ describe("chutes-models", () => {
await withLiveChutesDiscovery(mockFetch, async () => {
for (let i = 0; i < 150; i += 1) {
await chutesModels.discoverChutesModels(`cache-token-${i}`);
await discoverChutesModels(`cache-token-${i}`);
}
await chutesModels.discoverChutesModels("cache-token-0");
await discoverChutesModels("cache-token-0");
expect(mockFetch).toHaveBeenCalledTimes(151);
});
});
@@ -225,10 +226,10 @@ describe("chutes-models", () => {
await withLiveChutesDiscovery(
mockFetch,
async () => {
await chutesModels.discoverChutesModels("token-a");
await discoverChutesModels("token-a");
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
await chutesModels.discoverChutesModels("token-b");
await chutesModels.discoverChutesModels("token-a");
await discoverChutesModels("token-b");
await discoverChutesModels("token-a");
expect(mockFetch).toHaveBeenCalledTimes(3);
},
{ now: "2026-03-01T00:00:00.000Z" },
@@ -253,8 +254,8 @@ describe("chutes-models", () => {
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
await chutesModels.discoverChutesModels("failed-token");
await chutesModels.discoverChutesModels("failed-token");
await discoverChutesModels("failed-token");
await discoverChutesModels("failed-token");
expect(mockFetch).toHaveBeenCalledTimes(4);
});
});

View File

@@ -475,6 +475,10 @@ interface CacheEntry {
const modelCache = new Map<string, CacheEntry>();
export function clearChutesModelCacheForTests(): void {
modelCache.clear();
}
function pruneExpiredCacheEntries(now: number = Date.now()): void {
for (const [key, entry] of modelCache.entries()) {
if (now - entry.time >= CACHE_TTL) {

View File

@@ -428,10 +428,10 @@ function buildArtifactContext(
}
const artifactContext = {
agentId: normalizeContextString(context.agentId),
sessionId: normalizeContextString(context.sessionId),
messageChannel: normalizeContextString(context.messageChannel),
agentAccountId: normalizeContextString(context.agentAccountId),
agentId: normalizeOptionalString(context.agentId),
sessionId: normalizeOptionalString(context.sessionId),
messageChannel: normalizeOptionalString(context.messageChannel),
agentAccountId: normalizeOptionalString(context.agentAccountId),
};
return Object.values(artifactContext).some((value) => value !== undefined)
@@ -439,11 +439,6 @@ function buildArtifactContext(
: undefined;
}
function normalizeContextString(value: string | undefined): string | undefined {
const normalized = normalizeOptionalString(value);
return normalized ? normalized : undefined;
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;

View File

@@ -1,5 +1,6 @@
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
import {
createChannelApproverDmTargetResolver,
@@ -103,7 +104,7 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC
}),
resolveTurnSourceTarget: (request) => {
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);

View File

@@ -2,6 +2,7 @@ import type {
ChannelDirectoryEntry,
DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscord } from "./api.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
@@ -15,7 +16,7 @@ type DiscordChannel = { id: string; name?: string | null };
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
return normalizeOptionalLowercaseString(value) ?? "";
}
function buildUserRank(user: DiscordUser): number {

View File

@@ -30,6 +30,7 @@ import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { createDiscordRestClient } from "../client.js";
import {
parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomIdForCarbon,
@@ -512,6 +513,11 @@ async function dispatchDiscordComponentEvent(params: {
fallbackLimit: 2000,
});
const token = ctx.token ?? "";
const feedbackRest = createDiscordRestClient({
cfg: ctx.cfg,
token,
accountId,
}).rest;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(ctx.cfg, agentId);
const replyToMode =
ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off";
@@ -554,7 +560,7 @@ async function dispatchDiscordComponentEvent(params: {
onReplyStart: async () => {
try {
const { sendTyping } = await loadTypingRuntime();
await sendTyping({ client: interaction.client, channelId: typingChannelId });
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
} catch (err) {
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
}

View File

@@ -158,6 +158,9 @@ vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
) => configSessionsMocks.resolveStorePath(path, opts) as never) as never,
);
const clientModule = await import("../client.js");
const createDiscordRestClientSpy = vi.spyOn(clientModule, "createDiscordRestClient");
const BASE_CHANNEL_ROUTE = {
agentId: "main",
channel: "discord",
@@ -214,6 +217,7 @@ beforeEach(() => {
recordInboundSession.mockClear();
readSessionUpdatedAt.mockClear();
resolveStorePath.mockClear();
createDiscordRestClientSpy.mockClear();
dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult());
recordInboundSession.mockResolvedValue(undefined);
readSessionUpdatedAt.mockReturnValue(undefined);
@@ -278,7 +282,7 @@ function expectAckReactionRuntimeOptions(params?: {
messages.removeAckAfterReply = params.removeAckAfterReply;
}
return expect.objectContaining({
rest: {},
rest: expect.anything(),
...(Object.keys(messages).length > 0
? { cfg: expect.objectContaining({ messages: expect.objectContaining(messages) }) }
: {}),
@@ -337,7 +341,7 @@ function expectSinglePreviewEdit() {
"c1",
"preview-1",
{ content: "Hello\nWorld" },
{ rest: {} },
expect.objectContaining({ rest: expect.anything() }),
);
expect(deliverDiscordReply).not.toHaveBeenCalled();
}
@@ -397,6 +401,39 @@ describe("processDiscordMessage ack reactions", () => {
});
});
it("uses separate REST clients for feedback and reply delivery", async () => {
const feedbackRest = { post: vi.fn(async () => undefined) };
const deliveryRest = { post: vi.fn(async () => undefined) };
createDiscordRestClientSpy
.mockReturnValueOnce({
token: "feedback-token",
rest: feedbackRest as never,
account: { config: {} } as never,
})
.mockReturnValueOnce({
token: "delivery-token",
rest: deliveryRest as never,
account: { config: {} } as never,
});
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({ text: "hello" });
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
});
const ctx = await createBaseContext();
await runProcessDiscordMessage(ctx);
expect(sendMocks.reactMessageDiscord).toHaveBeenCalled();
expect(sendMocks.reactMessageDiscord.mock.calls[0]?.[3]).toEqual(
expect.objectContaining({ rest: feedbackRest }),
);
expect(deliverDiscordReply).toHaveBeenCalledWith(
expect.objectContaining({ rest: deliveryRest }),
);
expect(feedbackRest).not.toBe(deliveryRest);
});
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
@@ -733,7 +770,7 @@ describe("processDiscordMessage draft streaming", () => {
"c1",
"preview-1",
{ content: longReply },
{ rest: {} },
expect.objectContaining({ rest: expect.anything() }),
);
expect(deliverDiscordReply).not.toHaveBeenCalled();
});

View File

@@ -45,6 +45,7 @@ import {
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { createDiscordRestClient } from "../client.js";
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
import { createDiscordDraftStream } from "../draft-stream.js";
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
@@ -209,9 +210,19 @@ export async function processDiscordMessage(
const shouldSendAckReaction = shouldAckReaction();
const statusReactionsEnabled =
shouldSendAckReaction && cfg.messages?.statusReactions?.enabled !== false;
const feedbackRest = createDiscordRestClient({
cfg,
token,
accountId,
}).rest as unknown as RequestClient;
const deliveryRest = createDiscordRestClient({
cfg,
token,
accountId,
}).rest as unknown as RequestClient;
// Discord outbound helpers expect Carbon's request client shape explicitly.
const ackReactionContext = createDiscordAckReactionContext({
rest: client.rest as unknown as RequestClient,
rest: feedbackRest,
cfg,
accountId,
});
@@ -522,7 +533,7 @@ export async function processDiscordMessage(
channel: "discord",
accountId: route.accountId,
typing: {
start: () => sendTyping({ client, channelId: typingChannelId }),
start: () => sendTyping({ rest: feedbackRest, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
@@ -560,7 +571,7 @@ export async function processDiscordMessage(
: messageChannelId;
const draftStream = canStreamDraft
? createDiscordDraftStream({
rest: client.rest,
rest: deliveryRest,
channelId: deliverChannelId,
maxChars: draftMaxChars,
replyToMessageId: draftReplyToMessageId,
@@ -746,7 +757,7 @@ export async function processDiscordMessage(
deliverChannelId,
previewMessageId,
{ content: previewFinalText },
{ rest: client.rest },
{ rest: deliveryRest },
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
@@ -779,7 +790,7 @@ export async function processDiscordMessage(
deliverChannelId,
messageIdAfterStop,
{ content: previewFinalText },
{ rest: client.rest },
{ rest: deliveryRest },
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
@@ -812,7 +823,7 @@ export async function processDiscordMessage(
target: deliverTarget,
token,
accountId,
rest: client.rest,
rest: deliveryRest,
runtime,
replyToId,
replyToMode,

View File

@@ -95,18 +95,19 @@ async function runGuildSlashCommand(params?: {
}
function expectNotUnauthorizedReply(interaction: MockCommandInteraction) {
expect(interaction.reply).not.toHaveBeenCalledWith(
expect(interaction.followUp).not.toHaveBeenCalledWith(
expect.objectContaining({ content: "You are not authorized to use this command." }),
);
}
function expectUnauthorizedReply(interaction: MockCommandInteraction) {
expect(interaction.reply).toHaveBeenCalledWith(
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: "You are not authorized to use this command.",
ephemeral: true,
}),
);
expect(interaction.reply).not.toHaveBeenCalled();
}
describe("Discord native slash commands with commands.allowFrom", () => {
@@ -279,8 +280,10 @@ describe("Discord native slash commands with commands.allowFrom", () => {
| undefined;
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply }));
expect(interaction.followUp).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({ content: longReply }),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("swallows expired slash interactions before dispatch when defer returns Unknown interaction", async () => {

View File

@@ -282,9 +282,10 @@ async function expectPairCommandReply(params: {
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(params.interaction.reply).toHaveBeenCalledWith(
expect(params.interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({ content: "paired:now" }),
);
expect(params.interaction.reply).not.toHaveBeenCalled();
}
async function createStatusCommand(cfg: OpenClawConfig) {
@@ -465,12 +466,13 @@ describe("Discord native plugin command dispatch", () => {
expect(executeSpy).not.toHaveBeenCalled();
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: "You are not authorized to use this command.",
ephemeral: true,
}),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("rejects group DM slash commands outside dm.groupChannels before dispatch", async () => {
@@ -501,11 +503,12 @@ describe("Discord native plugin command dispatch", () => {
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({
content: "This group DM is not allowed.",
}),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
@@ -540,9 +543,10 @@ describe("Discord native plugin command dispatch", () => {
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect(interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({ content: "direct plugin output" }),
);
expect(interaction.reply).not.toHaveBeenCalled();
});
it("forwards Discord thread metadata into direct plugin command execution", async () => {

View File

@@ -715,7 +715,9 @@ export function createDiscordNativeCommand(params: {
discordConfig,
accountId,
sessionPrefix,
preferFollowUp: false,
// Slash commands are deferred up front, so all later responses must use
// follow-up/edit semantics instead of the initial reply endpoint.
preferFollowUp: true,
threadBindings,
});
}

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import {
DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
DEFAULT_THREAD_BINDING_MAX_AGE_MS,
@@ -112,14 +113,7 @@ export function normalizeTargetKind(
}
export function normalizeThreadId(raw: unknown): string | undefined {
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(Math.floor(raw));
}
if (typeof raw !== "string") {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
return normalizeOptionalStringifiedId(raw);
}
export function toBindingRecordKey(params: { accountId: string; threadId: string }): string {

View File

@@ -0,0 +1,42 @@
import { Routes } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { sendTyping } from "./typing.js";
describe("sendTyping", () => {
it("uses the direct Discord typing REST endpoint", async () => {
const rest = {
post: vi.fn(async () => {}),
};
await sendTyping({
// @ts-expect-error test stub only needs rest.post
rest,
channelId: "12345",
});
expect(rest.post).toHaveBeenCalledTimes(1);
expect(rest.post).toHaveBeenCalledWith(Routes.channelTyping("12345"));
});
it("times out when the typing endpoint hangs", async () => {
vi.useFakeTimers();
try {
const rest = {
post: vi.fn(() => new Promise(() => {})),
};
const promise = sendTyping({
// @ts-expect-error test stub only needs rest.post
rest,
channelId: "12345",
});
const rejection = expect(promise).rejects.toThrow("discord typing start timed out");
await vi.advanceTimersByTimeAsync(5_000);
await rejection;
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,11 +1,23 @@
import type { Client } from "@buape/carbon";
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
export async function sendTyping(params: { client: Client; channelId: string }) {
const channel = await params.client.fetchChannel(params.channelId);
if (!channel) {
return;
}
if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
await channel.triggerTyping();
const DISCORD_TYPING_START_TIMEOUT_MS = 5_000;
export async function sendTyping(params: { rest: RequestClient; channelId: string }) {
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(`discord typing start timed out after ${DISCORD_TYPING_START_TIMEOUT_MS}ms`),
);
}, DISCORD_TYPING_START_TIMEOUT_MS);
timer.unref?.();
});
try {
await Promise.race([params.rest.post(Routes.channelTyping(params.channelId)), timeoutPromise]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}

View File

@@ -1,3 +1,5 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
type DiscordSessionKeyContext = {
ChatType?: string;
From?: string;
@@ -5,7 +7,7 @@ type DiscordSessionKeyContext = {
};
function normalizeDiscordChatType(raw?: string): "direct" | "group" | "channel" | undefined {
const normalized = (raw ?? "").trim().toLowerCase();
const normalized = normalizeLowercaseStringOrEmpty(raw);
if (!normalized) {
return undefined;
}
@@ -22,7 +24,7 @@ export function normalizeExplicitDiscordSessionKey(
sessionKey: string,
ctx: DiscordSessionKeyContext,
): string {
let normalized = sessionKey.trim().toLowerCase();
let normalized = normalizeLowercaseStringOrEmpty(sessionKey);
if (normalizeDiscordChatType(ctx.ChatType) !== "direct") {
return normalized;
}
@@ -34,8 +36,8 @@ export function normalizeExplicitDiscordSessionKey(
return normalized;
}
const from = (ctx.From ?? "").trim().toLowerCase();
const senderId = (ctx.SenderId ?? "").trim().toLowerCase();
const from = normalizeLowercaseStringOrEmpty(ctx.From);
const senderId = normalizeLowercaseStringOrEmpty(ctx.SenderId);
const fromDiscordId =
from.startsWith("discord:") && !from.includes(":channel:") && !from.includes(":group:")
? from.slice("discord:".length)

View File

@@ -1,3 +1,4 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js";
@@ -9,7 +10,7 @@ export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000;
const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]);
export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean {
return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase());
return QUICK_ACTION_MENU_KEYS.has(normalizeOptionalLowercaseString(eventKey) ?? "");
}
export function createQuickActionLauncherCard(params: {

View File

@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
@@ -37,9 +38,9 @@ export async function listFeishuDirectoryPeersLive(params: {
throw new Error(response.msg || `code ${response.code}`);
}
const q = normalizeLowercaseStringOrEmpty(params.query);
for (const user of response.data?.items ?? []) {
if (user.open_id) {
const q = params.query?.trim().toLowerCase() || "";
const name = user.name || "";
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
peers.push({
@@ -90,9 +91,9 @@ export async function listFeishuDirectoryGroupsLive(params: {
throw new Error(response.msg || `code ${response.code}`);
}
const q = normalizeLowercaseStringOrEmpty(params.query);
for (const chat of response.data?.items ?? []) {
if (chat.chat_id) {
const q = params.query?.trim().toLowerCase() || "";
const name = chat.name || "";
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
groups.push({

View File

@@ -9,7 +9,7 @@ const GEMINI_MODEL_ALIASES: Record<string, string> = {
flash: "gemini-3.1-flash-preview",
"flash-lite": "gemini-3.1-flash-lite-preview",
};
const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3.1-pro-preview";
const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3-flash-preview";
export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
return {
@@ -17,17 +17,22 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
liveTest: {
defaultModelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
defaultImageProbe: true,
defaultMcpProbe: true,
docker: {
npmPackage: "@google/gemini-cli",
binaryName: "gemini",
},
},
bundleMcp: true,
bundleMcpMode: "gemini-system-settings",
config: {
command: "gemini",
args: ["--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"],
output: "json",
input: "arg",
imageArg: "@",
imagePathScope: "workspace",
modelArg: "--model",
modelAliases: GEMINI_MODEL_ALIASES,
sessionMode: "existing",

View File

@@ -3,6 +3,7 @@ import type {
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
const GEMINI_2_5_PRO_PREFIX = "gemini-2.5-pro";
@@ -54,7 +55,7 @@ function cloneGoogleTemplateModel(params: {
}
function isGoogleGeminiCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === GOOGLE_GEMINI_CLI_PROVIDER_ID;
return normalizeOptionalLowercaseString(providerId) === GOOGLE_GEMINI_CLI_PROVIDER_ID;
}
function templateIdsForProvider(

View File

@@ -3,7 +3,7 @@ import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import {
createChannelReplyPipeline,
@@ -73,7 +73,7 @@ export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => vo
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
const normalized = normalizeOptionalString(value)?.toLowerCase();
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
return "app-url";
}

View File

@@ -20,7 +20,7 @@ vi.mock("./onboard.js", () => ({
import plugin from "./index.js";
function _registerProvider() {
function registerProvider() {
return registerProviderWithPluginConfig({});
}
@@ -45,10 +45,20 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
describe("huggingface plugin", () => {
it("skips catalog discovery when plugin discovery is disabled", async () => {
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
const provider = registerProvider();
const result = await provider.catalog.run({
config: {},
config: {
plugins: {
entries: {
huggingface: {
config: {
discovery: { enabled: false },
},
},
},
},
},
resolveProviderApiKey: () => ({
apiKey: "hf_test_token",
discoveryApiKey: "hf_test_token",

View File

@@ -14,7 +14,7 @@ import {
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
import type { ResolvedIrcAccount } from "./accounts.js";
import { ircPlugin } from "./channel.js";
import { setIrcRuntime } from "./runtime.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import {
ircSetupAdapter,
parsePort,
@@ -82,6 +82,7 @@ function installIrcRuntime() {
describe("irc setup", () => {
afterEach(() => {
vi.clearAllMocks();
clearIrcRuntime();
});
it("parses valid ports and falls back for invalid values", () => {
@@ -404,13 +405,11 @@ describe("irc setup", () => {
it("keeps startAccount pending until abort, then stops the monitor", async () => {
const stop = vi.fn();
vi.resetModules();
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
installIrcRuntime();
const { ircPlugin: runtimeMockedPlugin } = await import("./channel.js");
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: runtimeMockedPlugin.gateway!.startAccount!,
startAccount: ircPlugin.gateway!.startAccount!,
account: buildAccount(),
});

View File

@@ -25,7 +25,7 @@ import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeAllowFrom } from "./bot-access.js";
import { resolveLineGroupConfigEntry } from "./group-keys.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
import type { ResolvedLineAccount } from "./types.js";
type EventSource = webhook.Source | undefined;
type MessageEvent = webhook.MessageEvent;
@@ -283,17 +283,6 @@ function resolveLineAddresses(params: {
return { fromAddress, toAddress, originatingTo };
}
function resolveLineGroupSystemPrompt(
groups: Record<string, LineGroupConfig | undefined> | undefined,
source: LineSourceInfoWithPeerId,
): string | undefined {
const entry = resolveLineGroupConfigEntry(groups, {
groupId: source.groupId,
roomId: source.roomId,
});
return normalizeOptionalString(entry?.systemPrompt);
}
async function finalizeLineInboundContext(params: {
cfg: OpenClawConfig;
account: ResolvedLineAccount;
@@ -380,7 +369,12 @@ async function finalizeLineInboundContext(params: {
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
GroupSystemPrompt: params.source.isGroup
? resolveLineGroupSystemPrompt(params.account.config.groups, params.source)
? normalizeOptionalString(
resolveLineGroupConfigEntry(params.account.config.groups, {
groupId: params.source.groupId,
roomId: params.source.roomId,
})?.systemPrompt,
)
: undefined,
InboundHistory: params.inboundHistory,
});

View File

@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -25,6 +25,7 @@ const {
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
let getLineRuntimeState: typeof import("./monitor.js").getLineRuntimeState;
let clearLineRuntimeStateForTests: typeof import("./monitor.js").clearLineRuntimeStateForTests;
let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
vi.mock("./bot.js", () => ({
@@ -92,8 +93,13 @@ vi.mock("./template-messages.js", () => ({
}));
describe("monitorLineProvider lifecycle", () => {
beforeEach(async () => {
vi.resetModules();
beforeAll(async () => {
({ monitorLineProvider, getLineRuntimeState, clearLineRuntimeStateForTests } =
await import("./monitor.js"));
});
beforeEach(() => {
clearLineRuntimeStateForTests();
createLineBotMock.mockReset();
createLineBotMock.mockReturnValue({
account: { accountId: "default" },
@@ -105,7 +111,6 @@ describe("monitorLineProvider lifecycle", () => {
.mockImplementation(() => innerLineWebhookHandlerMock);
unregisterHttpMock.mockReset();
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
({ monitorLineProvider, getLineRuntimeState } = await import("./monitor.js"));
});
const createRouteResponse = () => {

View File

@@ -97,6 +97,10 @@ export function getLineRuntimeState(accountId: string) {
return runtimeState.get(`line:${accountId}`);
}
export function clearLineRuntimeStateForTests() {
runtimeState.clear();
}
function startLineLoadingKeepalive(params: {
userId: string;
accountId?: string;

View File

@@ -8,7 +8,11 @@ import {
createChannelNativeOriginTargetResolver,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import {
getMatrixExecApprovalApprovers,
@@ -51,13 +55,8 @@ function resolveMatrixNativeTarget(raw: string): string | null {
return target.kind === "user" ? `user:${target.id}` : `room:${target.id}`;
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOriginTarget | null {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
const target = resolveMatrixNativeTarget(turnSourceTo);
if (turnSourceChannel !== "matrix" || !target) {
@@ -65,7 +64,7 @@ function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOr
}
return {
to: target,
threadId: normalizeThreadId(request.request.turnSourceThreadId),
threadId: normalizeOptionalStringifiedId(request.request.turnSourceThreadId),
};
}
@@ -79,7 +78,7 @@ function resolveSessionMatrixOriginTarget(sessionTarget: {
}
return {
to: target,
threadId: normalizeThreadId(sessionTarget.threadId),
threadId: normalizeOptionalStringifiedId(sessionTarget.threadId),
};
}

View File

@@ -12,6 +12,7 @@ import {
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import { matrixNativeApprovalAdapter } from "./approval-native.js";
import {
buildMatrixApprovalReactionHint,
@@ -95,11 +96,6 @@ function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string })
return isMatrixExecApprovalClientEnabled(params);
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
function buildPendingApprovalContent(params: {
request: ApprovalRequest;
nowMs: number;
@@ -290,7 +286,7 @@ export class MatrixExecApprovalHandler {
if (!target) {
return null;
}
const threadId = normalizeThreadId(rawTarget.threadId);
const threadId = normalizeOptionalStringifiedId(rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: this.opts.cfg as CoreConfig,

View File

@@ -11,6 +11,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
@@ -82,7 +83,9 @@ function matchesMatrixRequestAccount(params: {
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,

View File

@@ -1,3 +1,4 @@
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import { inspectMatrixDirectRooms, persistMatrixDirectRoomMapping } from "../direct-management.js";
import { isStrictDirectRoom } from "../direct-room.js";
import type { MatrixClient } from "../sdk.js";
@@ -12,11 +13,7 @@ function normalizeTarget(raw: string): string {
}
export function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) {
return null;
}
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
return normalizeOptionalStringifiedId(raw) ?? null;
}
// Size-capped to prevent unbounded growth (#4948)

View File

@@ -1,3 +1,4 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
import type {
@@ -8,7 +9,7 @@ import type {
} from "./runtime-api.js";
function normalizeLookupQuery(query: string): string {
return query.trim().toLowerCase();
return normalizeOptionalLowercaseString(query) ?? "";
}
function findExactDirectoryMatches(
@@ -20,9 +21,9 @@ function findExactDirectoryMatches(
return [];
}
return matches.filter((match) => {
const id = match.id.trim().toLowerCase();
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
const id = normalizeOptionalLowercaseString(match.id);
const name = normalizeOptionalLowercaseString(match.name);
const handle = normalizeOptionalLowercaseString(match.handle);
return normalized === id || normalized === name || normalized === handle;
});
}

View File

@@ -1,4 +1,5 @@
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -69,7 +70,7 @@ export async function listMattermostDirectoryGroups(
if (!clients.length) {
return [];
}
const q = params.query?.trim().toLowerCase() || "";
const q = normalizeLowercaseStringOrEmpty(params.query);
const seenIds = new Set<string>();
const entries: ChannelDirectoryEntry[] = [];
@@ -140,7 +141,7 @@ export async function listMattermostDirectoryPeers(
}
// Uses first team — multi-team setups may need iteration in the future
const teamId = teams[0].id;
const q = params.query?.trim().toLowerCase() || "";
const q = normalizeLowercaseStringOrEmpty(params.query);
let users: MattermostUser[];
if (q) {

View File

@@ -4,7 +4,9 @@ export {
DEFAULT_LOCAL_MODEL,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
registerBuiltInMemoryEmbeddingProviders,
} from "./src/memory/provider-adapters.js";
export { createEmbeddingProvider } from "./src/memory/embeddings.js";
export {
resolveMemoryCacheSummary,
resolveMemoryFtsState,

View File

@@ -105,8 +105,7 @@ When `render.createDashboards` is enabled, compile also maintains report dashboa
openclaw wiki status
openclaw wiki doctor
openclaw wiki init
openclaw wiki import ./notes/alpha.md
openclaw wiki import ~/Documents/KnowledgeVault
openclaw wiki ingest ./notes/alpha.md
openclaw wiki compile
openclaw wiki lint
openclaw wiki search "alpha"
@@ -131,16 +130,6 @@ openclaw wiki obsidian command workspace:quick-switcher
openclaw wiki obsidian daily
```
`wiki import` is the recommended path for local files, folders, and markdown vaults like Obsidian or Logseq. It auto-detects the best import profile when possible, writes imported artifacts as source pages, and records progress in the shared task ledger for larger imports. Markdown vault imports also preserve readable note bodies plus imported tags, aliases, and link hints instead of flattening every note into a fenced blob, and compile/search now use that imported metadata to rank the right pages faster.
Likely ChatGPT export JSON files now auto-detect into the `chatgpt-export` importer, which splits export bundles into conversation source pages instead of collapsing the whole dump into one giant local-file note.
Imported aliases also work as lookup keys for `wiki_get` and metadata updates, so imported vault notes stay addressable by their original note names instead of only by generated file paths.
Imported markdown vault relative paths also survive into lookup, retrieval, and `## Related` reconstruction, so `projects/alpha.md` keeps behaving like note identity instead of disappearing after import.
`reports/import-review.md` now also flags duplicate imported titles/aliases, duplicate note bodies, and obviously low-signal notes so large vault backfills are easier to triage before you start turning them into syntheses or claims.
## Agent tools
- `wiki_status`
@@ -170,7 +159,6 @@ Write methods:
- `wiki.init`
- `wiki.compile`
- `wiki.import`
- `wiki.ingest`
- `wiki.lint`
- `wiki.bridge.import`

View File

@@ -24,7 +24,6 @@ describe("memory-wiki plugin", () => {
"wiki.init",
"wiki.doctor",
"wiki.compile",
"wiki.import",
"wiki.ingest",
"wiki.lint",
"wiki.bridge.import",

View File

@@ -10,7 +10,7 @@ Use this skill when working inside a memory-wiki vault.
- Use `wiki_search` to discover candidate pages when you want wiki-specific ranking/provenance, then `wiki_get` to inspect the exact page before editing or citing it.
- Use `wiki_apply` for narrow synthesis filing and metadata updates when a tool-level mutation is enough.
- Run `wiki_lint` after meaningful wiki updates so contradictions, provenance gaps, and open questions get surfaced before you trust the vault.
- Use `openclaw wiki import`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
- Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory artifacts pulled in.
- In `unsafe-local` mode, use `openclaw wiki unsafe-local import` only when the user explicitly opted into private local path access.
- Keep generated sections inside managed markers. Do not overwrite human note blocks.

View File

@@ -162,58 +162,4 @@ keep this note
fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"),
).resolves.toContain("[Alpha](entities/alpha.md)");
});
it("resolves metadata updates through imported aliases", async () => {
const { rootDir, config } = await createVault({
prefix: "memory-wiki-apply-",
});
const targetPath = path.join(rootDir, "sources", "alpha-import.md");
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(
targetPath,
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.import.alpha",
title: "Alpha Imported Note",
sourceType: "markdown-vault",
importedAliases: ["Alpha Canon"],
status: "active",
},
body: `# Alpha Imported Note
## Notes
<!-- openclaw:human:start -->
keep imported note context
<!-- openclaw:human:end -->
`,
}),
"utf8",
);
const result = await applyMemoryWikiMutation({
config,
mutation: {
op: "update_metadata",
lookup: "alpha canon",
questions: ["Need to reconcile this imported note?"],
status: "review",
},
});
expect(result.changed).toBe(true);
expect(result.pagePath).toBe("sources/alpha-import.md");
const updated = await fs.readFile(targetPath, "utf8");
const parsed = parseWikiMarkdown(updated);
expect(parsed.frontmatter).toMatchObject({
id: "source.import.alpha",
title: "Alpha Imported Note",
importedAliases: ["Alpha Canon"],
questions: ["Need to reconcile this imported note?"],
status: "review",
});
expect(parsed.body).toContain("keep imported note context");
});
});

View File

@@ -77,50 +77,6 @@ describe("memory-wiki cli", () => {
);
});
it("registers import and auto-detects markdown vaults", async () => {
const { rootDir, config } = await createCliVault({ initialize: true });
const sourceRoot = path.join(suiteRoot, `case-${caseIndex++}`, "vault");
await fs.mkdir(path.join(sourceRoot, ".obsidian"), { recursive: true });
await fs.writeFile(
path.join(sourceRoot, "alpha.md"),
`# Alpha
cli import body
`,
"utf8",
);
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await program.parseAsync(["wiki", "import", sourceRoot, "--json"], { from: "user" });
const sourceEntries = await fs.readdir(path.join(rootDir, "sources"));
expect(
sourceEntries.filter((entry) => entry.endsWith(".md") && entry !== "index.md"),
).toHaveLength(1);
await expect(
fs.readFile(path.join(rootDir, "reports", "import-review.md"), "utf8"),
).resolves.toContain("Profile: `markdown-vault` (automatic)");
});
it("prints task guidance for non-json imports", async () => {
const { config } = await createCliVault({ initialize: true });
const sourceRoot = path.join(suiteRoot, `case-${caseIndex++}`, "task-vault");
await fs.mkdir(path.join(sourceRoot, ".obsidian"), { recursive: true });
await fs.writeFile(path.join(sourceRoot, "alpha.md"), "# Alpha\n\ncli import body\n", "utf8");
const program = new Command();
program.name("test");
registerWikiCli(program, config);
await program.parseAsync(["wiki", "import", sourceRoot], { from: "user" });
const writes = vi.mocked(process.stdout.write).mock.calls.map((call) => String(call[0]));
expect(writes.join("")).toContain("inspect with `openclaw tasks show ");
});
it("registers apply metadata and preserves the page body", async () => {
const { rootDir, config } = await createCliVault();
const targetPath = path.join(rootDir, "entities", "alpha.md");

View File

@@ -10,7 +10,7 @@ import {
type MemoryWikiPluginConfig,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { importMemoryWikiInput, WIKI_IMPORT_PROFILE_IDS } from "./import.js";
import { ingestMemoryWikiSource } from "./ingest.js";
import { lintMemoryWikiVault } from "./lint.js";
import {
probeObsidianCli,
@@ -54,11 +54,6 @@ type WikiIngestCommandOptions = {
title?: string;
};
type WikiImportCommandOptions = {
json?: boolean;
profile?: string;
};
type WikiSearchCommandOptions = {
json?: boolean;
maxResults?: number;
@@ -190,17 +185,6 @@ function formatJsonOrText<T>(
return json ? JSON.stringify(result, null, 2) : render(result);
}
function formatWikiImportSummary(value: Awaited<ReturnType<typeof importMemoryWikiInput>>): string {
const summary =
`Imported ${value.inputPath} via ${value.profileId} ` +
`(${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). ` +
`Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`;
if (!value.taskId) {
return summary;
}
return `${summary} Task ${value.taskId}; inspect with \`openclaw tasks show ${value.taskId}\`.`;
}
async function runWikiCommandWithSummary<T>(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
@@ -347,30 +331,13 @@ export async function runWikiIngest(params: {
json: params.json,
stdout: params.stdout,
run: () =>
runWikiImport({ config: params.config, inputPath: params.inputPath, title: params.title }),
render: formatWikiImportSummary,
});
}
export async function runWikiImport(params: {
config: ResolvedMemoryWikiConfig;
inputPath: string;
profileId?: string;
title?: string;
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
run: () =>
importMemoryWikiInput({
ingestMemoryWikiSource({
config: params.config,
inputPath: params.inputPath,
profileId: params.profileId,
title: params.title,
}),
render: formatWikiImportSummary,
render: (value) =>
`Ingested ${value.sourcePath} into ${value.pagePath}. Refreshed ${value.indexUpdatedFiles.length} index file${value.indexUpdatedFiles.length === 1 ? "" : "s"}.`,
});
}
@@ -675,16 +642,6 @@ export function registerWikiCli(
await runWikiLint({ config, appConfig, json: opts.json });
});
wiki
.command("import")
.description("Import a local file or markdown vault into wiki source pages")
.argument("<input>", "Local file or directory to import")
.option("--profile <id>", `Override import profile (${WIKI_IMPORT_PROFILE_IDS.join(", ")})`)
.option("--json", "Print JSON")
.action(async (inputPath: string, opts: WikiImportCommandOptions) => {
await runWikiImport({ config, inputPath, profileId: opts.profile, json: opts.json });
});
wiki
.command("ingest")
.description("Ingest a local file into the wiki sources folder")

View File

@@ -85,54 +85,6 @@ describe("compileMemoryWikiVault", () => {
).resolves.toContain('"text":"Alpha is the canonical source page."');
});
it("includes imported markdown-vault metadata in the agent digest", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "sources", "alpha-import.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.import.alpha",
title: "Alpha Import",
sourceType: "markdown-vault",
importRelativePath: "projects/alpha.md",
importedTags: ["project-alpha"],
importedAliases: ["Alpha Canon"],
importedLinkTargets: ["beta-project"],
},
body: "# Alpha Import\n\nImported markdown body.\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
const agentDigest = JSON.parse(
await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"),
) as {
pages: Array<{
path: string;
importRelativePath?: string;
importedTags?: string[];
importedAliases?: string[];
importedLinkTargets?: string[];
}>;
};
expect(agentDigest.pages).toContainEqual(
expect.objectContaining({
path: "sources/alpha-import.md",
importRelativePath: "projects/alpha.md",
importedTags: ["project-alpha"],
importedAliases: ["Alpha Canon"],
importedLinkTargets: ["beta-project"],
}),
);
});
it("renders obsidian-friendly links when configured", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
@@ -395,47 +347,4 @@ describe("compileMemoryWikiVault", () => {
fs.readFile(path.join(rootDir, "concepts", "gamma.md"), "utf8"),
).resolves.not.toContain("### Referenced By");
});
it("uses imported vault aliases and link targets for related backlinks", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "sources", "alpha-import.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.import.alpha",
title: "Alpha Note",
sourceType: "markdown-vault",
importRelativePath: "projects/alpha.md",
importedAliases: ["Alpha Canon"],
},
body: "# Alpha Note\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "beta-import.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.import.beta",
title: "Beta Note",
sourceType: "markdown-vault",
importedLinkTargets: ["projects/alpha.md"],
},
body: "# Beta Note\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
await expect(
fs.readFile(path.join(rootDir, "sources", "alpha-import.md"), "utf8"),
).resolves.toContain("[Beta Note](sources/beta-import.md)");
});
});

View File

@@ -369,24 +369,13 @@ function buildPageLookupKeys(page: WikiPageSummary): Set<string> {
const keys = new Set<string>();
keys.add(normalizeComparableTarget(page.relativePath));
keys.add(normalizeComparableTarget(page.relativePath.replace(/\.md$/i, "")));
if (page.importRelativePath) {
keys.add(normalizeComparableTarget(page.importRelativePath));
keys.add(normalizeComparableTarget(page.importRelativePath.replace(/\.md$/i, "")));
}
keys.add(normalizeComparableTarget(page.title));
for (const alias of page.importedAliases) {
keys.add(normalizeComparableTarget(alias));
}
if (page.id) {
keys.add(normalizeComparableTarget(page.id));
}
return keys;
}
function getPageReferenceTargets(page: WikiPageSummary): string[] {
return [...page.linkTargets, ...page.importedLinkTargets];
}
function renderWikiPageLinks(params: {
config: ResolvedMemoryWikiConfig;
pages: WikiPageSummary[];
@@ -429,7 +418,7 @@ function buildRelatedBlockBody(params: {
if (candidate.sourceIds.includes(params.page.id ?? "")) {
return true;
}
return getPageReferenceTargets(candidate).some((target) =>
return candidate.linkTargets.some((target) =>
backlinkKeys.has(normalizeComparableTarget(target)),
);
}),
@@ -717,13 +706,9 @@ type AgentDigestPage = {
title: string;
kind: WikiPageKind;
path: string;
importRelativePath?: string;
sourceIds: string[];
questions: string[];
contradictions: string[];
importedTags?: string[];
importedAliases?: string[];
importedLinkTargets?: string[];
confidence?: number;
freshnessLevel: WikiFreshnessLevel;
lastTouchedAt?: string;
@@ -856,15 +841,9 @@ function buildAgentDigest(params: {
title: page.title,
kind: page.kind,
path: page.relativePath,
...(page.importRelativePath ? { importRelativePath: page.importRelativePath } : {}),
sourceIds: [...page.sourceIds],
questions: [...page.questions],
contradictions: [...page.contradictions],
...(page.importedTags.length > 0 ? { importedTags: [...page.importedTags] } : {}),
...(page.importedAliases.length > 0 ? { importedAliases: [...page.importedAliases] } : {}),
...(page.importedLinkTargets.length > 0
? { importedLinkTargets: [...page.importedLinkTargets] }
: {}),
...(typeof page.confidence === "number" ? { confidence: page.confidence } : {}),
freshnessLevel: pageFreshness.level,
...(pageFreshness.lastTouchedAt ? { lastTouchedAt: pageFreshness.lastTouchedAt } : {}),

View File

@@ -5,7 +5,6 @@ import {
type ApplyMemoryWikiMutation,
} from "./apply.js";
import { registerMemoryWikiGatewayMethods } from "./gateway.js";
import { importMemoryWikiInput } from "./import.js";
import { ingestMemoryWikiSource } from "./ingest.js";
import { searchMemoryWiki } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
@@ -25,10 +24,6 @@ vi.mock("./ingest.js", () => ({
ingestMemoryWikiSource: vi.fn(),
}));
vi.mock("./import.js", () => ({
importMemoryWikiInput: vi.fn(),
}));
vi.mock("./lint.js", () => ({
lintMemoryWikiVault: vi.fn(),
}));
@@ -95,10 +90,6 @@ describe("memory-wiki gateway methods", () => {
vi.mocked(ingestMemoryWikiSource).mockResolvedValue({
pagePath: "sources/alpha-notes.md",
} as never);
vi.mocked(importMemoryWikiInput).mockResolvedValue({
pagePaths: ["sources/import-markdown-vault-alpha.md"],
profileId: "markdown-vault",
} as never);
vi.mocked(normalizeMemoryWikiMutationInput).mockReturnValue({
op: "create_synthesis",
title: "Gateway Alpha",
@@ -200,47 +191,6 @@ describe("memory-wiki gateway methods", () => {
);
});
it("forwards import requests over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
registerMemoryWikiGatewayMethods({ api, config });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.import");
if (!handler) {
throw new Error("wiki.import handler missing");
}
const respond = vi.fn();
await handler({
params: {
inputPath: "/tmp/alpha-vault",
profile: "markdown-vault",
sessionKey: "agent:main:test",
parentFlowId: "flow-1",
},
respond,
});
expect(importMemoryWikiInput).toHaveBeenCalledWith({
config,
inputPath: "/tmp/alpha-vault",
profileId: "markdown-vault",
taskContext: {
requesterSessionKey: "agent:main:test",
ownerKey: undefined,
parentFlowId: "flow-1",
parentTaskId: undefined,
agentId: undefined,
},
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
profileId: "markdown-vault",
}),
);
});
it("applies wiki mutations over the gateway", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();

View File

@@ -7,7 +7,6 @@ import {
WIKI_SEARCH_CORPORA,
type ResolvedMemoryWikiConfig,
} from "./config.js";
import { importMemoryWikiInput } from "./import.js";
import { ingestMemoryWikiSource } from "./ingest.js";
import { lintMemoryWikiVault } from "./lint.js";
import {
@@ -157,34 +156,6 @@ export function registerMemoryWikiGatewayMethods(params: {
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.import",
async ({ params: requestParams, respond }) => {
try {
const inputPath = readStringParam(requestParams, "inputPath", { required: true });
const profileId = readStringParam(requestParams, "profile");
respond(
true,
await importMemoryWikiInput({
config,
inputPath,
...(profileId ? { profileId } : {}),
taskContext: {
requesterSessionKey: readStringParam(requestParams, "sessionKey"),
ownerKey: readStringParam(requestParams, "ownerKey"),
parentFlowId: readStringParam(requestParams, "parentFlowId"),
parentTaskId: readStringParam(requestParams, "parentTaskId"),
agentId: readStringParam(requestParams, "agentId"),
},
}),
);
} catch (error) {
respondError(respond, error);
}
},
{ scope: WRITE_SCOPE },
);
api.registerGatewayMethod(
"wiki.ingest",
async ({ params: requestParams, respond }) => {

View File

@@ -1,340 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseChatGptExportFile } from "./import-chatgpt.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createTempDir } = createMemoryWikiTestHarness();
describe("parseChatGptExportFile", () => {
it("parses conversation arrays into transcript artifacts", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-export-");
const exportPath = path.join(dir, "chatgpt-export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-alpha",
title: "Alpha thread",
create_time: 1_710_000_000,
update_time: 1_710_000_100,
mapping: {
"2": {
message: {
author: { role: "assistant" },
create_time: 1_710_000_020,
content: { parts: ["hi there"] },
},
},
"1": {
message: {
author: { role: "user" },
create_time: 1_710_000_010,
content: { parts: ["hello alpha"] },
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]).toMatchObject({
conversationId: "conv-alpha",
title: "Alpha thread",
relativePath: expect.stringMatching(/^alpha-thread-/),
messageCount: 2,
participantRoles: ["assistant", "user"],
});
expect(conversations[0]?.transcriptBody).toContain("### User");
expect(conversations[0]?.transcriptBody).toContain("hello alpha");
expect(conversations[0]?.transcriptBody).toContain("### Assistant");
expect(conversations[0]?.transcriptBody).toContain("hi there");
});
it("parses conversations envelopes", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-envelope-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify({
conversations: [
{
conversation_id: "conv-envelope",
title: "Envelope thread",
mapping: {
root: {
message: {
author: { role: "user" },
content: { parts: ["hello from envelope"] },
},
},
},
},
],
}),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]).toMatchObject({
conversationId: "conv-envelope",
title: "Envelope thread",
messageCount: 1,
});
});
it("prefers the current_node branch instead of flattening alternate branches", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-branch-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-branch",
title: "Branch thread",
current_node: "assistant-good",
mapping: {
user: {
parent: null,
message: {
author: { role: "user" },
create_time: 1_710_000_010,
content: { parts: ["pick the right branch"] },
},
},
"assistant-bad": {
parent: "user",
message: {
author: { role: "assistant" },
create_time: 1_710_000_020,
content: { parts: ["wrong branch answer"] },
},
},
"assistant-good": {
parent: "user",
message: {
author: { role: "assistant" },
create_time: 1_710_000_030,
content: { parts: ["correct branch answer"] },
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]).toMatchObject({
conversationId: "conv-branch",
messageCount: 2,
});
expect(conversations[0]?.transcriptBody).toContain("pick the right branch");
expect(conversations[0]?.transcriptBody).toContain("correct branch answer");
expect(conversations[0]?.transcriptBody).not.toContain("wrong branch answer");
});
it("extracts readable text from object-shaped content parts", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-rich-parts-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-rich",
title: "Rich parts thread",
mapping: {
root: {
message: {
author: { role: "assistant" },
content: {
parts: [
{ text: "first rich paragraph" },
{ content: { text: "second rich paragraph" } },
],
},
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]?.transcriptBody).toContain("first rich paragraph");
expect(conversations[0]?.transcriptBody).toContain("second rich paragraph");
});
it("preserves lineage order when current_node messages have missing timestamps", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-no-times-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-no-times",
title: "No times thread",
current_node: "assistant-final",
mapping: {
user: {
parent: null,
message: {
author: { role: "user" },
content: { parts: ["first turn"] },
},
},
"assistant-final": {
parent: "user",
message: {
author: { role: "assistant" },
content: { parts: ["second turn"] },
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]?.transcriptBody.indexOf("first turn")).toBeLessThan(
conversations[0]?.transcriptBody.indexOf("second turn") ?? -1,
);
});
it("preserves source order when fallback transcript messages share the same timestamp", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-same-times-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-same-times",
title: "Same times thread",
mapping: {
"1": {
message: {
author: { role: "assistant" },
create_time: 1_710_000_010,
content: { parts: ["first exported message"] },
},
},
"2": {
message: {
author: { role: "user" },
create_time: 1_710_000_010,
content: { parts: ["second exported message"] },
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]?.transcriptBody.indexOf("first exported message")).toBeLessThan(
conversations[0]?.transcriptBody.indexOf("second exported message") ?? -1,
);
});
it("skips hidden and tool messages from imported transcripts", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-hidden-tool-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-hidden-tool",
title: "Hidden tool thread",
mapping: {
visible: {
message: {
author: { role: "user" },
content: { parts: ["visible user turn"] },
},
},
hidden: {
message: {
author: { role: "assistant" },
metadata: { is_visually_hidden_from_conversation: true },
content: { parts: ["hidden assistant turn"] },
},
},
tool: {
message: {
author: { role: "tool" },
content: { parts: ["tool output"] },
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]?.transcriptBody).toContain("visible user turn");
expect(conversations[0]?.transcriptBody).not.toContain("hidden assistant turn");
expect(conversations[0]?.transcriptBody).not.toContain("tool output");
});
it("drops conversations with no readable visible messages", async () => {
const dir = await createTempDir("memory-wiki-chatgpt-empty-visible-");
const exportPath = path.join(dir, "export.json");
await fs.writeFile(
exportPath,
JSON.stringify([
{
id: "conv-empty",
title: "Empty visible thread",
mapping: {
hidden: {
message: {
author: { role: "assistant" },
metadata: { is_visually_hidden_from_conversation: true },
content: { parts: ["hidden assistant turn"] },
},
},
tool: {
message: {
author: { role: "tool" },
content: { parts: ["tool output"] },
},
},
},
},
{
id: "conv-visible",
title: "Visible thread",
mapping: {
root: {
message: {
author: { role: "user" },
content: { parts: ["keep me"] },
},
},
},
},
]),
"utf8",
);
const conversations = await parseChatGptExportFile(exportPath);
expect(conversations).toHaveLength(1);
expect(conversations[0]).toMatchObject({
conversationId: "conv-visible",
title: "Visible thread",
});
});
});

View File

@@ -1,311 +0,0 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import { slugifyWikiSegment } from "./markdown.js";
type ChatGptMessage = {
role: string;
text: string;
sortTime: number;
sourceIndex: number;
};
type ChatGptMappingNode = {
id: string;
parentId?: string;
message: ChatGptMessage | null;
};
export type ChatGptExportConversation = {
conversationId: string;
title: string;
relativePath: string;
transcriptBody: string;
messageCount: number;
participantRoles: string[];
conversationCreatedAt?: string;
conversationUpdatedAt?: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeTimestamp(value: unknown): { iso?: string; sortTime: number } {
if (typeof value === "number" && Number.isFinite(value)) {
const ms = value > 1_000_000_000_000 ? value : value * 1000;
const date = new Date(ms);
return Number.isNaN(date.getTime())
? { sortTime: 0 }
: { iso: date.toISOString(), sortTime: ms };
}
if (typeof value === "string" && value.trim()) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? { sortTime: 0 }
: { iso: date.toISOString(), sortTime: date.getTime() };
}
return { sortTime: 0 };
}
function normalizeRole(value: unknown): string {
if (typeof value === "string" && value.trim()) {
return value.trim().toLowerCase();
}
return "unknown";
}
function formatRoleHeading(role: string): string {
return role
.split(/[^a-z0-9]+/i)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function collectTextFragments(value: unknown): string[] {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => collectTextFragments(entry));
}
const record = asRecord(value);
if (!record) {
return [];
}
for (const key of ["text", "parts", "content"]) {
if (key in record) {
const fragments = collectTextFragments(record[key]);
if (fragments.length > 0) {
return fragments;
}
}
}
return [];
}
function extractMessageText(contentValue: unknown): string {
const content = asRecord(contentValue);
if (!content) {
return "";
}
if (Array.isArray(content.parts)) {
return collectTextFragments(content.parts).join("\n\n");
}
if (typeof content.text === "string" && content.text.trim()) {
return content.text.trim();
}
if (Array.isArray(content.text)) {
return collectTextFragments(content.text).join("\n\n");
}
return "";
}
function shouldImportConversationMessage(params: {
role: string;
message: Record<string, unknown>;
}): boolean {
if (params.role === "tool") {
return false;
}
const metadata = asRecord(params.message.metadata);
if (metadata?.is_visually_hidden_from_conversation === true) {
return false;
}
return true;
}
function compareChatGptMessages(left: ChatGptMessage, right: ChatGptMessage): number {
if (left.sortTime !== right.sortTime) {
if (left.sortTime === 0) {
return 1;
}
if (right.sortTime === 0) {
return -1;
}
return left.sortTime - right.sortTime;
}
return left.sourceIndex - right.sourceIndex;
}
function extractConversationMessages(mappingValue: unknown): ChatGptMessage[] {
const mapping = asRecord(mappingValue);
if (!mapping) {
return [];
}
return Object.values(mapping)
.flatMap((entry, sourceIndex) => {
const node = asRecord(entry);
const message = asRecord(node?.message);
if (!message) {
return [];
}
const text = extractMessageText(message.content);
if (!text) {
return [];
}
const author = asRecord(message.author);
const role = normalizeRole(author?.role ?? author?.name);
if (!shouldImportConversationMessage({ role, message })) {
return [];
}
const { sortTime } = normalizeTimestamp(message.create_time ?? node?.create_time);
return [
{
role,
text,
sortTime,
sourceIndex,
},
];
})
.toSorted(compareChatGptMessages);
}
function extractConversationMappingNodes(mappingValue: unknown): ChatGptMappingNode[] {
const mapping = asRecord(mappingValue);
if (!mapping) {
return [];
}
return Object.entries(mapping).map(([id, entry], sourceIndex) => {
const node = asRecord(entry);
const message = asRecord(node?.message);
const text = extractMessageText(message?.content);
const author = asRecord(message?.author);
const role = normalizeRole(author?.role ?? author?.name);
const { sortTime } = normalizeTimestamp(message?.create_time ?? node?.create_time);
return {
id,
parentId:
typeof node?.parent === "string" && node.parent.trim() ? node.parent.trim() : undefined,
message:
text && message && shouldImportConversationMessage({ role, message })
? {
role,
text,
sortTime,
sourceIndex,
}
: null,
};
});
}
function extractCurrentConversationMessages(params: {
mappingValue: unknown;
currentNodeId?: unknown;
}): ChatGptMessage[] {
const nodes = extractConversationMappingNodes(params.mappingValue);
if (
nodes.length === 0 ||
typeof params.currentNodeId !== "string" ||
!params.currentNodeId.trim()
) {
return extractConversationMessages(params.mappingValue);
}
const byId = new Map(nodes.map((node) => [node.id, node] as const));
const lineage: ChatGptMessage[] = [];
const seen = new Set<string>();
let cursor: string | undefined = params.currentNodeId.trim();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const node = byId.get(cursor);
if (!node) {
break;
}
if (node.message) {
lineage.push(node.message);
}
cursor = node.parentId;
}
if (lineage.length === 0) {
return extractConversationMessages(params.mappingValue);
}
return lineage.toReversed();
}
function renderTranscriptBody(messages: ChatGptMessage[]): string {
if (messages.length === 0) {
return "_No readable ChatGPT transcript messages were found in this export conversation._";
}
return messages
.flatMap((message) => [`### ${formatRoleHeading(message.role)}`, "", message.text, ""])
.join("\n")
.trim();
}
function resolveConversationRecords(parsed: unknown): Record<string, unknown>[] {
if (Array.isArray(parsed)) {
return parsed.flatMap((entry) => {
const record = asRecord(entry);
return record ? [record] : [];
});
}
const envelope = asRecord(parsed);
if (Array.isArray(envelope?.conversations)) {
return envelope.conversations.flatMap((entry) => {
const record = asRecord(entry);
return record ? [record] : [];
});
}
return [];
}
function resolveConversationId(record: Record<string, unknown>, index: number): string {
for (const key of ["id", "conversation_id", "conversationId"]) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return `conversation-${index + 1}`;
}
export async function parseChatGptExportFile(
inputPath: string,
): Promise<ChatGptExportConversation[]> {
const raw = await fs.readFile(inputPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const records = resolveConversationRecords(parsed);
if (records.length === 0) {
throw new Error(`No ChatGPT conversations found in export: ${inputPath}`);
}
const conversations = records.flatMap((record, index) => {
const conversationId = resolveConversationId(record, index);
const title =
(typeof record.title === "string" && record.title.trim()) || `Conversation ${index + 1}`;
const created = normalizeTimestamp(record.create_time);
const updated = normalizeTimestamp(record.update_time);
const messages = extractCurrentConversationMessages({
mappingValue: record.mapping,
currentNodeId: record.current_node,
});
if (messages.length === 0) {
return [];
}
const participantRoles = [...new Set(messages.map((message) => message.role))].toSorted();
const relativeSlug = slugifyWikiSegment(title);
const idHash = createHash("sha1").update(conversationId).digest("hex").slice(0, 8);
return [
{
conversationId,
title,
relativePath: `${relativeSlug}-${idHash}.md`,
transcriptBody: renderTranscriptBody(messages),
messageCount: messages.length,
participantRoles,
...(created.iso ? { conversationCreatedAt: created.iso } : {}),
...(updated.iso ? { conversationUpdatedAt: updated.iso } : {}),
},
];
});
if (conversations.length === 0) {
throw new Error(`No readable ChatGPT conversations found in export: ${inputPath}`);
}
return conversations;
}

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { detectChatGptExportSample } from "./import-profile-detect.js";
describe("detectChatGptExportSample", () => {
it("detects likely ChatGPT exports by filename", () => {
expect(
detectChatGptExportSample({
inputPath: "/tmp/chatgpt-export.json",
sample: JSON.stringify({ anything: true }),
}),
).toBe(true);
});
it("detects likely ChatGPT conversation exports by JSON structure", () => {
expect(
detectChatGptExportSample({
inputPath: "/tmp/export.json",
sample: JSON.stringify([
{
title: "Alpha thread",
mapping: {
root: {
message: {
author: { role: "user" },
content: { parts: ["hello"] },
},
},
},
},
]),
}),
).toBe(true);
});
it("does not flag generic JSON files as ChatGPT exports", () => {
expect(
detectChatGptExportSample({
inputPath: "/tmp/project-notes.json",
sample: JSON.stringify({
title: "Alpha notes",
body: "just a normal note export",
}),
}),
).toBe(false);
});
});

View File

@@ -1,54 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
function looksLikeChatGptConversationRecord(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
const title = record.title;
const mapping = record.mapping;
return typeof title === "string" && !!mapping && typeof mapping === "object";
}
function looksLikeChatGptConversationsEnvelope(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
const conversations = record.conversations;
return (
Array.isArray(conversations) &&
conversations.some((entry) => looksLikeChatGptConversationRecord(entry))
);
}
export function detectChatGptExportSample(params: { inputPath: string; sample: string }): boolean {
const basename = path.basename(params.inputPath).toLowerCase();
if (basename.includes("chatgpt")) {
return true;
}
try {
const parsed = JSON.parse(params.sample) as unknown;
return (
(Array.isArray(parsed) &&
parsed.some((entry) => looksLikeChatGptConversationRecord(entry))) ||
looksLikeChatGptConversationsEnvelope(parsed)
);
} catch {
return false;
}
}
export async function detectChatGptExportFile(inputPath: string): Promise<boolean> {
const ext = path.extname(inputPath).toLowerCase();
if (ext !== ".json") {
return false;
}
const sample = await fs.readFile(inputPath, "utf8").catch(() => null);
if (!sample) {
return false;
}
return detectChatGptExportSample({ inputPath, sample });
}

View File

@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildImportBodyDuplicateClusters,
buildImportDuplicateClusters,
buildImportReviewBody,
buildLowSignalImportEntries,
type ImportReviewEntry,
} from "./import-review.js";
const entries: ImportReviewEntry[] = [
{
title: "Shared Title",
relativePath: "alpha.md",
pagePath: "sources/alpha.md",
importedAliases: ["Shared Alias"],
importedTags: ["alpha"],
bodyTextLength: 120,
nonEmptyLineCount: 5,
bodyFingerprint: "body-dup-1",
},
{
title: "Shared Title",
relativePath: "beta.md",
pagePath: "sources/beta.md",
importedAliases: ["Shared Alias"],
importedTags: ["beta"],
bodyTextLength: 110,
nonEmptyLineCount: 4,
bodyFingerprint: "body-dup-1",
},
{
title: "Tiny",
relativePath: "tiny.md",
pagePath: "sources/tiny.md",
importedAliases: [],
importedTags: [],
bodyTextLength: 12,
nonEmptyLineCount: 2,
bodyFingerprint: "tiny-body",
},
];
describe("import-review", () => {
it("clusters duplicate imported titles and aliases", () => {
const clusters = buildImportDuplicateClusters(entries);
expect(clusters[0]).toMatchObject({
label: "Shared Alias",
entryCount: 2,
});
expect(clusters[1]).toMatchObject({
label: "Shared Title",
entryCount: 2,
});
});
it("flags low-signal imported entries", () => {
expect(buildLowSignalImportEntries(entries)).toEqual([
expect.objectContaining({ relativePath: "tiny.md" }),
]);
});
it("clusters duplicate imported note bodies", () => {
expect(buildImportBodyDuplicateClusters(entries)).toEqual([
expect.objectContaining({
fingerprint: "body-dup-1",
entryCount: 2,
}),
]);
});
it("renders duplicate and low-signal sections in the review body", () => {
const body = buildImportReviewBody({
inputPath: "/tmp/vault",
profileId: "markdown-vault",
profileResolution: "automatic",
artifactCount: 3,
importedCount: 3,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
pagePaths: entries.map((entry) => entry.pagePath),
reviewEntries: entries,
});
expect(body).toContain("## Duplicate Title/Alias Clusters");
expect(body).toContain("`Shared Title` (2 notes)");
expect(body).toContain("## Duplicate Body Clusters");
expect(body).toContain("`body-dup-1` (2 notes)");
expect(body).toContain("## Low-Signal Sources");
expect(body).toContain("`tiny.md` (Tiny): 2 non-empty lines, 12 characters");
});
});

View File

@@ -1,168 +0,0 @@
export type ImportReviewEntry = {
title: string;
relativePath: string;
pagePath: string;
importedAliases: string[];
importedTags: string[];
bodyTextLength: number;
nonEmptyLineCount: number;
bodyFingerprint?: string;
};
function normalizeImportReviewKey(value: string): string {
return value.trim().toLowerCase();
}
export function buildImportDuplicateClusters(reviewEntries: ImportReviewEntry[]): Array<{
label: string;
entryCount: number;
entries: ImportReviewEntry[];
}> {
const clusters = new Map<string, { label: string; entries: ImportReviewEntry[] }>();
for (const entry of reviewEntries) {
for (const label of [entry.title, ...entry.importedAliases]) {
const normalized = normalizeImportReviewKey(label);
if (!normalized) {
continue;
}
const current = clusters.get(normalized) ?? { label, entries: [] };
if (!current.entries.some((candidate) => candidate.pagePath === entry.pagePath)) {
current.entries.push(entry);
}
clusters.set(normalized, current);
}
}
return [...clusters.values()]
.filter((cluster) => cluster.entries.length > 1)
.map((cluster) => ({
label: cluster.label,
entryCount: cluster.entries.length,
entries: [...cluster.entries].toSorted((left, right) =>
left.relativePath.localeCompare(right.relativePath),
),
}))
.toSorted((left, right) => {
if (left.entryCount !== right.entryCount) {
return right.entryCount - left.entryCount;
}
return left.label.localeCompare(right.label);
});
}
export function buildLowSignalImportEntries(
reviewEntries: ImportReviewEntry[],
): ImportReviewEntry[] {
return reviewEntries
.filter((entry) => entry.bodyTextLength < 80 || entry.nonEmptyLineCount <= 2)
.toSorted((left, right) => left.relativePath.localeCompare(right.relativePath));
}
export function buildImportBodyDuplicateClusters(reviewEntries: ImportReviewEntry[]): Array<{
fingerprint: string;
entryCount: number;
entries: ImportReviewEntry[];
}> {
const clusters = new Map<string, ImportReviewEntry[]>();
for (const entry of reviewEntries) {
const fingerprint = entry.bodyFingerprint?.trim();
if (!fingerprint || entry.bodyTextLength < 80) {
continue;
}
const current = clusters.get(fingerprint) ?? [];
current.push(entry);
clusters.set(fingerprint, current);
}
return [...clusters.entries()]
.filter(([, entries]) => entries.length > 1)
.map(([fingerprint, entries]) => ({
fingerprint,
entryCount: entries.length,
entries: [...entries].toSorted((left, right) =>
left.relativePath.localeCompare(right.relativePath),
),
}))
.toSorted((left, right) => {
if (left.entryCount !== right.entryCount) {
return right.entryCount - left.entryCount;
}
return left.fingerprint.localeCompare(right.fingerprint);
});
}
export function buildImportReviewBody(params: {
inputPath: string;
profileId: string;
profileResolution: "automatic" | "explicit";
artifactCount: number;
importedCount: number;
updatedCount: number;
skippedCount: number;
removedCount: number;
pagePaths: string[];
reviewEntries: ImportReviewEntry[];
}): string {
const lines = [
"# Import Review",
"",
"## Summary",
`- Input: \`${params.inputPath}\``,
`- Profile: \`${params.profileId}\` (${params.profileResolution})`,
`- Artifacts discovered: ${params.artifactCount}`,
`- Imported: ${params.importedCount}`,
`- Updated: ${params.updatedCount}`,
`- Unchanged: ${params.skippedCount}`,
`- Removed: ${params.removedCount}`,
"",
"## Imported Pages",
];
if (params.pagePaths.length === 0) {
lines.push("- No importable pages were written.");
} else {
for (const pagePath of params.pagePaths) {
lines.push(`- ${pagePath}`);
}
}
const duplicateClusters = buildImportDuplicateClusters(params.reviewEntries);
lines.push("", "## Duplicate Title/Alias Clusters");
if (duplicateClusters.length === 0) {
lines.push("- No duplicate title or alias clusters detected.");
} else {
for (const cluster of duplicateClusters) {
lines.push(
`- \`${cluster.label}\` (${cluster.entryCount} notes): ${cluster.entries
.map((entry) => `\`${entry.relativePath}\``)
.join(", ")}`,
);
}
}
const bodyDuplicateClusters = buildImportBodyDuplicateClusters(params.reviewEntries);
lines.push("", "## Duplicate Body Clusters");
if (bodyDuplicateClusters.length === 0) {
lines.push("- No duplicate imported note bodies detected.");
} else {
for (const cluster of bodyDuplicateClusters) {
lines.push(
`- \`${cluster.fingerprint}\` (${cluster.entryCount} notes): ${cluster.entries
.map((entry) => `\`${entry.relativePath}\``)
.join(", ")}`,
);
}
}
const lowSignalEntries = buildLowSignalImportEntries(params.reviewEntries);
lines.push("", "## Low-Signal Sources");
if (lowSignalEntries.length === 0) {
lines.push("- No obviously low-signal imported sources detected.");
} else {
for (const entry of lowSignalEntries) {
lines.push(
`- \`${entry.relativePath}\` (${entry.title}): ${entry.nonEmptyLineCount} non-empty lines, ${entry.bodyTextLength} characters`,
);
}
}
lines.push("");
return lines.join("\n");
}

View File

@@ -1,302 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { importMemoryWikiInput } from "./import.js";
import { parseWikiMarkdown } from "./markdown.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createTempDir, createVault } = createMemoryWikiTestHarness();
describe("memory-wiki import", () => {
it("imports a single local file through the unified import runner", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const sourceRoot = await createTempDir("memory-wiki-import-file-");
const sourcePath = path.join(sourceRoot, "alpha-notes.md");
await fs.writeFile(
sourcePath,
`# Alpha Notes
alpha body
`,
"utf8",
);
const result = await importMemoryWikiInput({
config,
inputPath: sourcePath,
});
expect(result.profileId).toBe("local-file");
expect(result.importedCount).toBe(1);
await expect(fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8")).resolves.toContain(
"sourceType: local-file",
);
await expect(fs.readFile(path.join(rootDir, result.reportPath), "utf8")).resolves.toContain(
"Profile: `local-file` (automatic)",
);
expect(result.taskId).toBeTruthy();
expect(result.runId).toBeTruthy();
});
it("auto-detects markdown vaults and skips vault metadata directories", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const vaultPath = await createTempDir("memory-wiki-import-vault-");
await fs.mkdir(path.join(vaultPath, ".obsidian"), { recursive: true });
await fs.mkdir(path.join(vaultPath, "projects"), { recursive: true });
await fs.writeFile(
path.join(vaultPath, "alpha.md"),
`---
tags:
- alpha
aliases:
- Alpha Note
---
# Alpha
alpha body with [[Beta Project|Beta]] and [Plan](projects/beta.md).
`,
"utf8",
);
await fs.writeFile(
path.join(vaultPath, "projects", "beta.md"),
"# Beta\n\nbeta body\n",
"utf8",
);
await fs.writeFile(path.join(vaultPath, ".obsidian", "workspace.json"), "{}", "utf8");
const result = await importMemoryWikiInput({
config,
inputPath: vaultPath,
});
expect(result.profileId).toBe("markdown-vault");
expect(result.artifactCount).toBe(2);
expect(result.importedCount).toBe(2);
const importedPage = await fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8");
const parsedImportedPage = parseWikiMarkdown(importedPage);
expect(parsedImportedPage.frontmatter).toMatchObject({
sourceType: "markdown-vault",
importRelativePath: "alpha.md",
importedTags: ["alpha"],
importedAliases: ["Alpha Note"],
importedLinkTargets: ["Beta Project", "projects/beta.md"],
});
expect(parsedImportedPage.body).toContain("alpha body with Beta and Plan (projects/beta.md).");
expect(parsedImportedPage.body).not.toContain("[[Beta Project|Beta]]");
const sourceEntries = await fs.readdir(path.join(rootDir, "sources"));
expect(
sourceEntries.filter((entry) => entry.endsWith(".md") && entry !== "index.md"),
).toHaveLength(2);
await expect(fs.readFile(path.join(rootDir, result.reportPath), "utf8")).resolves.toContain(
"Profile: `markdown-vault` (automatic)",
);
});
it("auto-detects logseq vaults and skips logseq metadata directories", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const vaultPath = await createTempDir("memory-wiki-import-logseq-");
await fs.mkdir(path.join(vaultPath, "logseq"), { recursive: true });
await fs.writeFile(path.join(vaultPath, "alpha.md"), "# Alpha\n\nlogseq vault body\n", "utf8");
await fs.writeFile(
path.join(vaultPath, "logseq", "settings.md"),
"# Settings\n\nskip me\n",
"utf8",
);
const result = await importMemoryWikiInput({
config,
inputPath: vaultPath,
});
expect(result.profileId).toBe("markdown-vault");
expect(result.artifactCount).toBe(1);
expect(result.importedCount).toBe(1);
await expect(fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8")).resolves.toContain(
"logseq vault body",
);
});
it("imports chatgpt-export files explicitly as conversation sources", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const sourceRoot = await createTempDir("memory-wiki-import-placeholder-");
const sourcePath = path.join(sourceRoot, "chatgpt-export.json");
await fs.writeFile(
sourcePath,
JSON.stringify([
{
id: "conv-alpha",
title: "Alpha thread",
create_time: 1_710_000_000,
update_time: 1_710_000_100,
mapping: {
root: {
message: {
author: { role: "user" },
content: { parts: ["hello alpha"] },
},
},
branch: {
message: {
author: { role: "assistant" },
content: { parts: ["hi there"] },
},
},
},
},
]),
"utf8",
);
const result = await importMemoryWikiInput({
config,
inputPath: sourcePath,
profileId: "chatgpt-export",
});
expect(result.profileId).toBe("chatgpt-export");
expect(result.importedCount).toBe(1);
const importedPage = await fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8");
const parsedImportedPage = parseWikiMarkdown(importedPage);
expect(parsedImportedPage.frontmatter).toMatchObject({
sourceType: "chatgpt-export",
importProfile: "chatgpt-export",
importedTags: ["chatgpt-export"],
importedAliases: ["conv-alpha"],
});
expect(parsedImportedPage.body).toContain("## Conversation Transcript");
expect(parsedImportedPage.body).toContain("### User");
expect(parsedImportedPage.body).toContain("hello alpha");
expect(parsedImportedPage.body).toContain("### Assistant");
expect(parsedImportedPage.body).toContain("hi there");
});
it("auto-detects likely ChatGPT export files into the chatgpt-export importer", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const sourceRoot = await createTempDir("memory-wiki-import-chatgpt-auto-");
const sourcePath = path.join(sourceRoot, "export.json");
await fs.writeFile(
sourcePath,
JSON.stringify([
{
title: "Alpha thread",
mapping: {
root: {
message: {
author: { role: "user" },
content: { parts: ["hello"] },
},
},
},
},
]),
"utf8",
);
const result = await importMemoryWikiInput({
config,
inputPath: sourcePath,
});
expect(result.profileId).toBe("chatgpt-export");
expect(result.importedCount).toBe(1);
await expect(fs.readFile(path.join(rootDir, result.reportPath), "utf8")).resolves.toContain(
"Profile: `chatgpt-export` (automatic)",
);
});
it("skips hidden-only ChatGPT conversations during import", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const sourceRoot = await createTempDir("memory-wiki-import-chatgpt-skip-empty-");
const sourcePath = path.join(sourceRoot, "export.json");
await fs.writeFile(
sourcePath,
JSON.stringify([
{
id: "conv-hidden",
title: "Hidden thread",
mapping: {
hidden: {
message: {
author: { role: "assistant" },
metadata: { is_visually_hidden_from_conversation: true },
content: { parts: ["hidden turn"] },
},
},
},
},
{
id: "conv-visible",
title: "Visible thread",
mapping: {
root: {
message: {
author: { role: "user" },
content: { parts: ["visible turn"] },
},
},
},
},
]),
"utf8",
);
const result = await importMemoryWikiInput({
config,
inputPath: sourcePath,
profileId: "chatgpt-export",
});
expect(result.profileId).toBe("chatgpt-export");
expect(result.artifactCount).toBe(1);
expect(result.importedCount).toBe(1);
expect(result.pagePaths).toHaveLength(1);
const importedPage = await fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8");
expect(importedPage).toContain("visible turn");
expect(importedPage).not.toContain("hidden turn");
});
it("writes duplicate and low-signal review sections for vault imports", async () => {
const { rootDir, config } = await createVault({ initialize: true });
const vaultPath = await createTempDir("memory-wiki-import-review-");
await fs.mkdir(path.join(vaultPath, ".obsidian"), { recursive: true });
await fs.writeFile(
path.join(vaultPath, "alpha.md"),
`---
aliases:
- Shared Alias
---
# Shared Title
This imported note has enough substance to avoid the low-signal bucket.
`,
"utf8",
);
await fs.writeFile(
path.join(vaultPath, "beta.md"),
`---
aliases:
- Shared Alias
---
# Shared Title
Another imported note with enough text to trigger duplicate clustering.
`,
"utf8",
);
await fs.writeFile(path.join(vaultPath, "tiny.md"), "# Tiny\n\nok\n", "utf8");
const result = await importMemoryWikiInput({
config,
inputPath: vaultPath,
});
const report = await fs.readFile(path.join(rootDir, result.reportPath), "utf8");
expect(report).toContain("## Duplicate Title/Alias Clusters");
expect(report).toContain("`Shared Title` (2 notes)");
expect(report).toContain("## Low-Signal Sources");
expect(report).toContain("`tiny.md` (Tiny):");
});
});

View File

@@ -1,905 +0,0 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
completePluginTaskRun,
createPluginTaskRun,
failPluginTaskRun,
recordPluginTaskProgress,
} from "openclaw/plugin-sdk/core";
import { normalizeSingleOrTrimmedStringList } from "openclaw/plugin-sdk/text-runtime";
import { compileMemoryWikiVault, type CompileMemoryWikiResult } from "./compile.js";
import type { ResolvedMemoryWikiConfig } from "./config.js";
import { parseChatGptExportFile, type ChatGptExportConversation } from "./import-chatgpt.js";
import { detectChatGptExportFile } from "./import-profile-detect.js";
import { buildImportReviewBody, type ImportReviewEntry } from "./import-review.js";
import { appendMemoryWikiLog } from "./log.js";
import {
extractWikiLinks,
extractTitleFromMarkdown,
parseWikiMarkdown,
renderMarkdownFence,
renderWikiMarkdown,
slugifyWikiSegment,
} from "./markdown.js";
import { writeImportedSourcePage } from "./source-page-shared.js";
import { pathExists, resolveArtifactKey } from "./source-path-shared.js";
import {
pruneImportedSourceEntries,
readMemoryWikiSourceSyncState,
writeMemoryWikiSourceSyncState,
} from "./source-sync-state.js";
import { initializeMemoryWikiVault } from "./vault.js";
const DIRECTORY_TEXT_EXTENSIONS = new Set([
".json",
".jsonl",
".md",
".markdown",
".txt",
".yaml",
".yml",
]);
const MARKDOWN_VAULT_EXTENSIONS = new Set([".md", ".markdown"]);
const MARKDOWN_VAULT_MARKERS = [".obsidian", "logseq"] as const;
const IMPORT_TASK_KIND = "memory-wiki-import";
const IMPORT_OWNER_KEY = "memory-wiki:import";
const IMPORT_REVIEW_PATH = "reports/import-review.md";
const IMPORTED_OBSIDIAN_LINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
const IMPORTED_MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
export const WIKI_IMPORT_PROFILE_IDS = [
"local-file",
"directory-text",
"markdown-vault",
"chatgpt-export",
] as const;
export type WikiImportProfileId = (typeof WIKI_IMPORT_PROFILE_IDS)[number];
type WikiImportArtifact = {
absolutePath: string;
relativePath: string;
profileId: Exclude<WikiImportProfileId, "chatgpt-export">;
importRootPath: string;
sourceType: string;
};
type PreparedImportArtifact = {
title: string;
importedTags: string[];
importedAliases: string[];
importedLinkTargets: string[];
renderedContentBody: string;
bodyTextLength: number;
nonEmptyLineCount: number;
bodyFingerprint: string;
};
type WikiImportTaskContext = {
requesterSessionKey?: string;
ownerKey?: string;
requesterOrigin?: Parameters<typeof createPluginTaskRun>[0]["requesterOrigin"];
parentFlowId?: string;
parentTaskId?: string;
agentId?: string;
};
export type WikiImportResult = {
inputPath: string;
profileId: WikiImportProfileId;
profileResolution: "automatic" | "explicit";
artifactCount: number;
importedCount: number;
updatedCount: number;
skippedCount: number;
removedCount: number;
pagePaths: string[];
reportPath: string;
indexesRefreshed: boolean;
indexUpdatedFiles: string[];
indexRefreshReason: "compiled" | "auto-compile-disabled";
taskId?: string;
runId?: string;
};
type WikiImportProfileResolution = {
profileId: WikiImportProfileId;
profileResolution: "automatic" | "explicit";
};
type ImportWriteResult = {
pagePath: string;
changed: boolean;
created: boolean;
reviewEntry: ImportReviewEntry;
};
function normalizeImportProfileId(value?: string): WikiImportProfileId | undefined {
const normalized = value?.trim();
if (!normalized) {
return undefined;
}
return (WIKI_IMPORT_PROFILE_IDS as readonly string[]).includes(normalized)
? (normalized as WikiImportProfileId)
: undefined;
}
function detectFenceLanguage(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".json" || ext === ".jsonl") {
return "json";
}
if (ext === ".yaml" || ext === ".yml") {
return "yaml";
}
if (ext === ".txt") {
return "text";
}
return "markdown";
}
function assertUtf8Text(buffer: Buffer, sourcePath: string): string {
const preview = buffer.subarray(0, Math.min(buffer.length, 4096));
if (preview.includes(0)) {
throw new Error(`Cannot import binary file as text source: ${sourcePath}`);
}
return buffer.toString("utf8");
}
function humanizeImportPath(value: string): string {
return value
.replace(/\\/g, "/")
.replace(/\.[^.]+$/, "")
.replace(/[-_]+/g, " ")
.replace(/\//g, " / ")
.trim();
}
function resolveImportArtifactTitle(params: {
relativePath: string;
raw: string;
profileId: WikiImportArtifact["profileId"];
titleOverride?: string;
}): string {
if (params.titleOverride?.trim()) {
return params.titleOverride.trim();
}
if (params.profileId === "local-file") {
return (
extractTitleFromMarkdown(params.raw) ?? humanizeImportPath(path.basename(params.relativePath))
);
}
return extractTitleFromMarkdown(params.raw) ?? humanizeImportPath(params.relativePath);
}
function normalizeImportedAliases(frontmatter: Record<string, unknown>): string[] {
const aliases = normalizeSingleOrTrimmedStringList(frontmatter.aliases);
if (aliases.length > 0) {
return aliases;
}
return normalizeSingleOrTrimmedStringList(frontmatter.alias);
}
function renderMarkdownVaultBodyForEvidence(body: string): string {
const withoutWikilinks = body.replace(
IMPORTED_OBSIDIAN_LINK_PATTERN,
(_match: string, rawTarget: string, rawLabel?: string) => {
const target = rawTarget.trim();
const label = rawLabel?.trim();
return label || target;
},
);
return withoutWikilinks.replace(
IMPORTED_MARKDOWN_LINK_PATTERN,
(match: string, label: string, rawTarget: string) => {
const target = rawTarget.trim();
if (!target || target.startsWith("#") || /^[a-z]+:/i.test(target)) {
return match;
}
const normalizedTarget = target.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim();
return normalizedTarget ? `${label} (${normalizedTarget})` : label;
},
);
}
function buildImportBodyMetrics(text: string): {
bodyTextLength: number;
nonEmptyLineCount: number;
} {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return {
bodyTextLength: lines.join(" ").length,
nonEmptyLineCount: lines.length,
};
}
function buildImportBodyFingerprint(text: string): string {
const normalized = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
}
function prepareImportArtifact(params: {
artifact: WikiImportArtifact;
raw: string;
titleOverride?: string;
}): PreparedImportArtifact {
const title = resolveImportArtifactTitle({
relativePath: params.artifact.relativePath,
raw: params.raw,
profileId: params.artifact.profileId,
titleOverride: params.artifact.profileId === "local-file" ? params.titleOverride : undefined,
});
if (params.artifact.profileId !== "markdown-vault") {
const metrics = buildImportBodyMetrics(params.raw);
return {
title,
importedTags: [],
importedAliases: [],
importedLinkTargets: [],
renderedContentBody: renderMarkdownFence(
params.raw,
detectFenceLanguage(params.artifact.absolutePath),
),
bodyFingerprint: buildImportBodyFingerprint(params.raw),
...metrics,
};
}
const parsed = parseWikiMarkdown(params.raw);
const importedTags = normalizeSingleOrTrimmedStringList(parsed.frontmatter.tags);
const importedAliases = normalizeImportedAliases(parsed.frontmatter);
const importedLinkTargets = extractWikiLinks(parsed.body);
const renderedContentBody = renderMarkdownVaultBodyForEvidence(parsed.body).trim();
const metrics = buildImportBodyMetrics(renderedContentBody);
return {
title,
importedTags,
importedAliases,
importedLinkTargets,
renderedContentBody:
renderedContentBody.length > 0
? renderedContentBody
: "_Imported markdown note body was empty._",
bodyFingerprint: buildImportBodyFingerprint(renderedContentBody),
...metrics,
};
}
function shouldSkipMarkdownVaultDir(relativePath: string): boolean {
const normalized = relativePath.replace(/\\/g, "/").replace(/\/+$/g, "");
if (!normalized) {
return false;
}
return (
normalized === ".obsidian" ||
normalized.startsWith(".obsidian/") ||
normalized === "logseq" ||
normalized.startsWith("logseq/") ||
normalized === ".git" ||
normalized.startsWith(".git/") ||
normalized === "node_modules" ||
normalized.startsWith("node_modules/") ||
normalized === ".trash" ||
normalized.startsWith(".trash/") ||
normalized === ".logseq" ||
normalized.startsWith(".logseq/")
);
}
async function listImportFilesRecursive(params: {
rootDir: string;
allowedExtensions: ReadonlySet<string>;
skipDir?: (relativePath: string) => boolean;
}): Promise<string[]> {
async function walk(relativeDir: string): Promise<string[]> {
const fullDir = relativeDir ? path.join(params.rootDir, relativeDir) : params.rootDir;
const entries = await fs.readdir(fullDir, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;
if (entry.isDirectory()) {
if (params.skipDir?.(relativePath)) {
continue;
}
files.push(...(await walk(relativePath)));
continue;
}
const ext = path.extname(entry.name).toLowerCase();
if (entry.isFile() && params.allowedExtensions.has(ext)) {
files.push(relativePath.replace(/\\/g, "/"));
}
}
return files;
}
return (await walk("")).toSorted((left, right) => left.localeCompare(right));
}
async function isMarkdownVaultRoot(inputPath: string): Promise<boolean> {
for (const marker of MARKDOWN_VAULT_MARKERS) {
if (await pathExists(path.join(inputPath, marker))) {
return true;
}
}
return false;
}
async function resolveWikiImportProfile(params: {
inputPath: string;
profileId?: WikiImportProfileId;
}): Promise<WikiImportProfileResolution> {
if (params.profileId) {
return {
profileId: params.profileId,
profileResolution: "explicit",
};
}
const stat = await fs.stat(params.inputPath).catch(() => null);
if (!stat) {
throw new Error(`Import path not found: ${params.inputPath}`);
}
if (stat.isFile()) {
const ext = path.extname(params.inputPath).toLowerCase();
if (!DIRECTORY_TEXT_EXTENSIONS.has(ext)) {
throw new Error(`Import path is not a supported text source: ${params.inputPath}`);
}
if (await detectChatGptExportFile(params.inputPath)) {
return {
profileId: "chatgpt-export",
profileResolution: "automatic",
};
}
return {
profileId: "local-file",
profileResolution: "automatic",
};
}
if (!stat.isDirectory()) {
throw new Error(`Import path must be a file or directory: ${params.inputPath}`);
}
if (await isMarkdownVaultRoot(params.inputPath)) {
return {
profileId: "markdown-vault",
profileResolution: "automatic",
};
}
return {
profileId: "directory-text",
profileResolution: "automatic",
};
}
async function enumerateImportArtifacts(params: {
inputPath: string;
profileId: Exclude<WikiImportProfileId, "chatgpt-export">;
}): Promise<WikiImportArtifact[]> {
if (params.profileId === "local-file") {
return [
{
absolutePath: path.resolve(params.inputPath),
relativePath: path.basename(params.inputPath),
profileId: "local-file",
importRootPath: path.dirname(path.resolve(params.inputPath)),
sourceType: "local-file",
},
];
}
const inputRoot = path.resolve(params.inputPath);
const relativePaths = await listImportFilesRecursive({
rootDir: inputRoot,
allowedExtensions:
params.profileId === "markdown-vault" ? MARKDOWN_VAULT_EXTENSIONS : DIRECTORY_TEXT_EXTENSIONS,
...(params.profileId === "markdown-vault" ? { skipDir: shouldSkipMarkdownVaultDir } : {}),
});
return relativePaths.map((relativePath) => ({
absolutePath: path.join(inputRoot, relativePath),
relativePath,
profileId: params.profileId,
importRootPath: inputRoot,
sourceType: params.profileId === "markdown-vault" ? "markdown-vault" : "directory-text",
}));
}
function resolveImportScopeKey(params: {
inputPath: string;
profileId: WikiImportProfileId;
}): string {
return `${params.profileId}:${path.resolve(params.inputPath)}`;
}
function resolveImportPageIdentity(artifact: WikiImportArtifact): {
pageId: string;
pagePath: string;
} {
const rootHash = createHash("sha1").update(artifact.importRootPath).digest("hex").slice(0, 8);
const relativeHash = createHash("sha1").update(artifact.relativePath).digest("hex").slice(0, 8);
const artifactSlug = slugifyWikiSegment(
artifact.relativePath.replace(/\.[^.]+$/, "").replace(/[\\/]+/g, "-"),
);
return {
pageId: `source.import.${artifact.profileId}.${rootHash}.${relativeHash}`,
pagePath: path
.join(
"sources",
`import-${artifact.profileId}-${rootHash}-${artifactSlug}-${relativeHash}.md`,
)
.replace(/\\/g, "/"),
};
}
async function writeImportReviewReport(params: {
config: ResolvedMemoryWikiConfig;
inputPath: string;
profileId: WikiImportProfileId;
profileResolution: "automatic" | "explicit";
artifactCount: number;
importedCount: number;
updatedCount: number;
skippedCount: number;
removedCount: number;
pagePaths: string[];
reviewEntries: ImportReviewEntry[];
}): Promise<string> {
const reportPath = path.join(params.config.vault.path, IMPORT_REVIEW_PATH);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(
reportPath,
renderWikiMarkdown({
frontmatter: {
pageType: "report",
id: "report.import-review",
title: "Import Review",
status: "active",
sourceType: "wiki-import-report",
updatedAt: new Date().toISOString(),
},
body: buildImportReviewBody(params),
}),
"utf8",
);
return IMPORT_REVIEW_PATH;
}
async function writeImportArtifactPage(params: {
config: ResolvedMemoryWikiConfig;
artifact: WikiImportArtifact;
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
scopeKey: string;
titleOverride?: string;
}): Promise<ImportWriteResult> {
const stats = await fs.stat(params.artifact.absolutePath);
const raw = assertUtf8Text(
await fs.readFile(params.artifact.absolutePath),
params.artifact.absolutePath,
);
const prepared = prepareImportArtifact({
artifact: params.artifact,
raw,
titleOverride: params.titleOverride,
});
const { pageId, pagePath } = resolveImportPageIdentity(params.artifact);
const renderFingerprint = createHash("sha1")
.update(
JSON.stringify({
profileId: params.artifact.profileId,
sourceType: params.artifact.sourceType,
importRootPath: params.artifact.importRootPath,
relativePath: params.artifact.relativePath,
title: prepared.title,
importedTags: prepared.importedTags,
importedAliases: prepared.importedAliases,
importedLinkTargets: prepared.importedLinkTargets,
}),
)
.digest("hex");
const writeResult = await writeImportedSourcePage({
vaultRoot: params.config.vault.path,
syncKey: await resolveArtifactKey(params.artifact.absolutePath),
sourcePath: params.artifact.absolutePath,
sourceUpdatedAtMs: stats.mtimeMs,
sourceSize: stats.size,
renderFingerprint,
pagePath,
group: "import",
scopeKey: params.scopeKey,
state: params.state,
buildRendered: (_existingRaw, updatedAt) =>
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: pageId,
title: prepared.title,
sourceType: params.artifact.sourceType,
sourcePath: params.artifact.absolutePath,
importProfile: params.artifact.profileId,
importRootPath: params.artifact.importRootPath,
importRelativePath: params.artifact.relativePath,
...(prepared.importedTags.length > 0 ? { importedTags: prepared.importedTags } : {}),
...(prepared.importedAliases.length > 0
? { importedAliases: prepared.importedAliases }
: {}),
...(prepared.importedLinkTargets.length > 0
? { importedLinkTargets: prepared.importedLinkTargets }
: {}),
status: "active",
updatedAt,
},
body: [
`# ${prepared.title}`,
"",
"## Imported Source",
`- Profile: \`${params.artifact.profileId}\``,
`- Root: \`${params.artifact.importRootPath}\``,
`- Relative path: \`${params.artifact.relativePath}\``,
`- Updated: ${updatedAt}`,
...(prepared.importedTags.length > 0
? [`- Imported tags: ${prepared.importedTags.map((tag) => `\`${tag}\``).join(", ")}`]
: []),
...(prepared.importedAliases.length > 0
? [
`- Imported aliases: ${prepared.importedAliases
.map((alias) => `\`${alias}\``)
.join(", ")}`,
]
: []),
...(prepared.importedLinkTargets.length > 0
? [
`- Imported links: ${prepared.importedLinkTargets
.map((target) => `\`${target}\``)
.join(", ")}`,
]
: []),
"",
params.artifact.profileId === "markdown-vault" ? "## Imported Markdown" : "## Content",
prepared.renderedContentBody,
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
}),
});
return {
...writeResult,
reviewEntry: {
title: prepared.title,
relativePath: params.artifact.relativePath,
pagePath,
importedAliases: [...prepared.importedAliases],
importedTags: [...prepared.importedTags],
bodyTextLength: prepared.bodyTextLength,
nonEmptyLineCount: prepared.nonEmptyLineCount,
bodyFingerprint: prepared.bodyFingerprint,
},
};
}
async function writeChatGptConversationPage(params: {
config: ResolvedMemoryWikiConfig;
exportPath: string;
conversation: ChatGptExportConversation;
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
scopeKey: string;
}): Promise<ImportWriteResult> {
const stats = await fs.stat(params.exportPath);
const exportHash = createHash("sha1").update(params.exportPath).digest("hex").slice(0, 8);
const conversationHash = createHash("sha1")
.update(params.conversation.conversationId)
.digest("hex")
.slice(0, 8);
const conversationSlug = slugifyWikiSegment(params.conversation.title);
const pageId = `source.import.chatgpt-export.${exportHash}.${conversationHash}`;
const pagePath = path
.join(
"sources",
`import-chatgpt-export-${exportHash}-${conversationSlug}-${conversationHash}.md`,
)
.replace(/\\/g, "/");
const renderFingerprint = createHash("sha1")
.update(
JSON.stringify({
profileId: "chatgpt-export",
exportPath: params.exportPath,
conversationId: params.conversation.conversationId,
title: params.conversation.title,
relativePath: params.conversation.relativePath,
transcriptBody: params.conversation.transcriptBody,
messageCount: params.conversation.messageCount,
participantRoles: params.conversation.participantRoles,
conversationCreatedAt: params.conversation.conversationCreatedAt,
conversationUpdatedAt: params.conversation.conversationUpdatedAt,
}),
)
.digest("hex");
const rendered = renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: pageId,
title: params.conversation.title,
sourceType: "chatgpt-export",
sourcePath: params.exportPath,
importProfile: "chatgpt-export",
importRootPath: path.dirname(params.exportPath),
importRelativePath: params.conversation.relativePath,
importedTags: ["chatgpt-export"],
importedAliases: [params.conversation.conversationId],
status: "active",
updatedAt: new Date(stats.mtimeMs).toISOString(),
},
body: [
`# ${params.conversation.title}`,
"",
"## Imported Source",
"- Profile: `chatgpt-export`",
`- Export file: \`${params.exportPath}\``,
`- Relative path: \`${params.conversation.relativePath}\``,
`- Conversation id: \`${params.conversation.conversationId}\``,
...(params.conversation.conversationCreatedAt
? [`- Conversation created: ${params.conversation.conversationCreatedAt}`]
: []),
...(params.conversation.conversationUpdatedAt
? [`- Conversation updated: ${params.conversation.conversationUpdatedAt}`]
: []),
`- Messages: ${params.conversation.messageCount}`,
...(params.conversation.participantRoles.length > 0
? [
`- Participants: ${params.conversation.participantRoles
.map((role) => `\`${role}\``)
.join(", ")}`,
]
: []),
"",
"## Conversation Transcript",
params.conversation.transcriptBody,
"",
"## Notes",
"<!-- openclaw:human:start -->",
"<!-- openclaw:human:end -->",
"",
].join("\n"),
});
const writeResult = await writeImportedSourcePage({
vaultRoot: params.config.vault.path,
syncKey: `chatgpt-export:${params.exportPath}#${params.conversation.conversationId}`,
sourcePath: params.exportPath,
sourceUpdatedAtMs: stats.mtimeMs,
sourceSize: stats.size,
renderFingerprint,
pagePath,
group: "import",
scopeKey: params.scopeKey,
state: params.state,
rendered,
});
const bodyMetrics = buildImportBodyMetrics(params.conversation.transcriptBody);
return {
...writeResult,
reviewEntry: {
title: params.conversation.title,
relativePath: params.conversation.relativePath,
pagePath,
importedAliases: [params.conversation.conversationId],
importedTags: ["chatgpt-export"],
bodyFingerprint: buildImportBodyFingerprint(params.conversation.transcriptBody),
...bodyMetrics,
},
};
}
export async function importMemoryWikiInput(params: {
config: ResolvedMemoryWikiConfig;
inputPath: string;
profileId?: string;
title?: string;
taskContext?: WikiImportTaskContext;
}): Promise<WikiImportResult> {
await initializeMemoryWikiVault(params.config);
const normalizedInputPath = path.resolve(params.inputPath);
const requestedProfileId = normalizeImportProfileId(params.profileId);
if (params.profileId && !requestedProfileId) {
throw new Error(
`Unknown import profile: ${params.profileId}. Expected one of: ${WIKI_IMPORT_PROFILE_IDS.join(", ")}`,
);
}
const taskHandle = createPluginTaskRun({
taskKind: IMPORT_TASK_KIND,
sourceId: "memory-wiki:import",
requesterSessionKey: params.taskContext?.requesterSessionKey,
ownerKey:
params.taskContext?.ownerKey ??
(params.taskContext?.requesterSessionKey ? undefined : IMPORT_OWNER_KEY),
requesterOrigin: params.taskContext?.requesterOrigin,
parentFlowId: params.taskContext?.parentFlowId,
parentTaskId: params.taskContext?.parentTaskId,
agentId: params.taskContext?.agentId,
label: "Wiki import",
task: `Import wiki sources from ${normalizedInputPath}`,
progressSummary: "Detecting import profile",
});
try {
const profile = await resolveWikiImportProfile({
inputPath: normalizedInputPath,
profileId: requestedProfileId,
});
recordPluginTaskProgress({
handle: taskHandle,
progressSummary: `Enumerating ${profile.profileId} sources`,
eventSummary: `Detected ${profile.profileId} import profile`,
});
const scopeKey = resolveImportScopeKey({
inputPath: normalizedInputPath,
profileId: profile.profileId,
});
const state = await readMemoryWikiSourceSyncState(params.config.vault.path);
const activeKeys = new Set<string>();
const results: ImportWriteResult[] = [];
let artifactCount = 0;
if (profile.profileId === "chatgpt-export") {
recordPluginTaskProgress({
handle: taskHandle,
progressSummary: "Reading chatgpt-export conversations",
});
const conversations = await parseChatGptExportFile(normalizedInputPath);
artifactCount = conversations.length;
for (const [index, conversation] of conversations.entries()) {
activeKeys.add(`chatgpt-export:${normalizedInputPath}#${conversation.conversationId}`);
recordPluginTaskProgress({
handle: taskHandle,
progressSummary: `Importing ${index + 1}/${conversations.length} conversations`,
eventSummary: conversation.title,
});
results.push(
await writeChatGptConversationPage({
config: params.config,
exportPath: normalizedInputPath,
conversation,
state,
scopeKey,
}),
);
}
} else {
const artifacts = await enumerateImportArtifacts({
inputPath: normalizedInputPath,
profileId: profile.profileId,
});
if (artifacts.length === 0) {
throw new Error(
`No importable sources found for ${profile.profileId}: ${normalizedInputPath}`,
);
}
artifactCount = artifacts.length;
for (const [index, artifact] of artifacts.entries()) {
activeKeys.add(await resolveArtifactKey(artifact.absolutePath));
recordPluginTaskProgress({
handle: taskHandle,
progressSummary: `Importing ${index + 1}/${artifacts.length} sources`,
eventSummary: artifact.relativePath,
});
results.push(
await writeImportArtifactPage({
config: params.config,
artifact,
state,
scopeKey,
titleOverride: params.title,
}),
);
}
}
const removedCount = await pruneImportedSourceEntries({
vaultRoot: params.config.vault.path,
group: "import",
activeKeys,
state,
scopeKey,
});
await writeMemoryWikiSourceSyncState(params.config.vault.path, state);
const pagePaths = results
.map((result) => result.pagePath)
.toSorted((left, right) => left.localeCompare(right));
const importedCount = results.filter((result) => result.changed && result.created).length;
const updatedCount = results.filter((result) => result.changed && !result.created).length;
const skippedCount = results.filter((result) => !result.changed).length;
recordPluginTaskProgress({
handle: taskHandle,
progressSummary: "Writing import review",
});
const reportPath = await writeImportReviewReport({
config: params.config,
inputPath: normalizedInputPath,
profileId: profile.profileId,
profileResolution: profile.profileResolution,
artifactCount,
importedCount,
updatedCount,
skippedCount,
removedCount,
pagePaths,
reviewEntries: results.map((result) => result.reviewEntry),
});
let compile: CompileMemoryWikiResult | null = null;
let indexRefreshReason: WikiImportResult["indexRefreshReason"] = "auto-compile-disabled";
if (params.config.ingest.autoCompile) {
recordPluginTaskProgress({
handle: taskHandle,
progressSummary: "Compiling wiki indexes",
});
compile = await compileMemoryWikiVault(params.config);
indexRefreshReason = "compiled";
}
await appendMemoryWikiLog(params.config.vault.path, {
type: "ingest",
timestamp: new Date().toISOString(),
details: {
sourceType: "memory-import",
inputPath: normalizedInputPath,
profileId: profile.profileId,
profileResolution: profile.profileResolution,
artifactCount,
importedCount,
updatedCount,
skippedCount,
removedCount,
reportPath,
},
});
const result: WikiImportResult = {
inputPath: normalizedInputPath,
profileId: profile.profileId,
profileResolution: profile.profileResolution,
artifactCount,
importedCount,
updatedCount,
skippedCount,
removedCount,
pagePaths,
reportPath,
indexesRefreshed: compile !== null,
indexUpdatedFiles: compile?.updatedFiles ?? [],
indexRefreshReason,
taskId: taskHandle.taskId,
runId: taskHandle.runId,
};
completePluginTaskRun({
handle: taskHandle,
progressSummary: `Imported ${artifactCount} sources`,
terminalSummary: `Imported ${artifactCount} sources via ${profile.profileId} (${importedCount} new, ${updatedCount} updated, ${skippedCount} unchanged, ${removedCount} removed).`,
});
return result;
} catch (error) {
failPluginTaskRun({
handle: taskHandle,
error,
progressSummary: "Wiki import failed",
terminalSummary: `Wiki import failed: ${error instanceof Error ? error.message : String(error)}`,
});
throw error;
}
}

View File

@@ -46,14 +46,10 @@ export type WikiPageSummary = {
claims: WikiClaim[];
contradictions: string[];
questions: string[];
importedTags: string[];
importedAliases: string[];
importedLinkTargets: string[];
confidence?: number;
sourceType?: string;
provenanceMode?: string;
sourcePath?: string;
importRelativePath?: string;
bridgeRelativePath?: string;
bridgeWorkspaceDir?: string;
unsafeLocalConfiguredPath?: string;
@@ -265,9 +261,6 @@ export function toWikiPageSummary(params: {
claims: normalizeWikiClaims(parsed.frontmatter.claims),
contradictions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.contradictions),
questions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.questions),
importedTags: normalizeSingleOrTrimmedStringList(parsed.frontmatter.importedTags),
importedAliases: normalizeSingleOrTrimmedStringList(parsed.frontmatter.importedAliases),
importedLinkTargets: normalizeSingleOrTrimmedStringList(parsed.frontmatter.importedLinkTargets),
confidence:
typeof parsed.frontmatter.confidence === "number" &&
Number.isFinite(parsed.frontmatter.confidence)
@@ -276,7 +269,6 @@ export function toWikiPageSummary(params: {
sourceType: normalizeOptionalString(parsed.frontmatter.sourceType),
provenanceMode: normalizeOptionalString(parsed.frontmatter.provenanceMode),
sourcePath: normalizeOptionalString(parsed.frontmatter.sourcePath),
importRelativePath: normalizeOptionalString(parsed.frontmatter.importRelativePath),
bridgeRelativePath: normalizeOptionalString(parsed.frontmatter.bridgeRelativePath),
bridgeWorkspaceDir: normalizeOptionalString(parsed.frontmatter.bridgeWorkspaceDir),
unsafeLocalConfiguredPath: normalizeOptionalString(

View File

@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveQueryableWikiPageByLookup } from "./query-lookup.js";
describe("resolveQueryableWikiPageByLookup", () => {
const pages = [
{
relativePath: "sources/alpha-import.md",
title: "Alpha Imported Note",
id: "source.import.alpha",
importedAliases: ["Alpha Canon"],
importRelativePath: "projects/alpha.md",
},
{
relativePath: "entities/beta.md",
title: "Beta",
id: "entity.beta",
importedAliases: [],
importRelativePath: undefined,
},
];
it("resolves pages by title", () => {
expect(resolveQueryableWikiPageByLookup(pages, "Alpha Imported Note")).toMatchObject({
relativePath: "sources/alpha-import.md",
});
});
it("resolves pages by imported alias case-insensitively", () => {
expect(resolveQueryableWikiPageByLookup(pages, "alpha canon")).toMatchObject({
relativePath: "sources/alpha-import.md",
});
});
it("resolves pages by imported vault-relative path", () => {
expect(resolveQueryableWikiPageByLookup(pages, "projects/alpha")).toMatchObject({
relativePath: "sources/alpha-import.md",
});
});
});

View File

@@ -1,47 +0,0 @@
import path from "node:path";
import type { WikiPageSummary } from "./markdown.js";
export type QueryableWikiLookupPage = Pick<
WikiPageSummary,
"relativePath" | "title" | "id" | "importedAliases" | "importRelativePath"
>;
export function normalizeLookupKey(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
}
function normalizeLookupLabel(value: string): string {
return value
.trim()
.replace(/\\/g, "/")
.replace(/\.md$/i, "")
.replace(/^\.\/+/, "")
.replace(/\/+$/, "")
.toLowerCase();
}
export function resolveQueryableWikiPageByLookup<T extends QueryableWikiLookupPage>(
pages: T[],
lookup: string,
): T | null {
const key = normalizeLookupKey(lookup);
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
const normalizedLabel = normalizeLookupLabel(lookup);
return (
pages.find((page) => page.relativePath === key) ??
pages.find((page) => page.relativePath === withExtension) ??
pages.find((page) => page.relativePath.replace(/\.md$/i, "") === key) ??
pages.find((page) => path.basename(page.relativePath, ".md") === key) ??
pages.find((page) => page.importRelativePath === key) ??
pages.find((page) => page.importRelativePath === withExtension) ??
pages.find((page) => page.importRelativePath?.replace(/\.md$/i, "") === key) ??
pages.find((page) => path.basename(page.importRelativePath ?? "", ".md") === key) ??
pages.find((page) => page.id === key) ??
pages.find((page) => normalizeLookupLabel(page.title) === normalizedLabel) ??
pages.find((page) =>
page.importedAliases.some((alias) => normalizeLookupLabel(alias) === normalizedLabel),
) ??
null
);
}

View File

@@ -153,49 +153,6 @@ describe("searchMemoryWiki", () => {
});
});
it("finds imported markdown-vault pages by imported aliases and tags", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "sources", "alpha-import.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.import.alpha",
title: "Alpha Project Note",
sourceType: "markdown-vault",
importedTags: ["project-alpha"],
importedAliases: ["Alpha Canon"],
importedLinkTargets: ["beta-project"],
},
body: `# Alpha Project Note
## Imported Source
- Imported tags: \`project-alpha\`
- Imported aliases: \`Alpha Canon\`
- Imported links: \`beta-project\`
## Imported Markdown
Alpha project planning notes.
`,
}),
"utf8",
);
const aliasResults = await searchMemoryWiki({ config, query: "alpha canon" });
expect(aliasResults[0]).toMatchObject({
corpus: "wiki",
path: "sources/alpha-import.md",
});
const tagResults = await searchMemoryWiki({ config, query: "project-alpha" });
expect(tagResults[0]).toMatchObject({
corpus: "wiki",
path: "sources/alpha-import.md",
});
});
it("ranks fresh supported claims ahead of stale contested claims", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
@@ -572,46 +529,6 @@ describe("getMemoryWikiPage", () => {
});
});
it("resolves wiki pages by title and imported alias", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "sources", "alpha-import.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.import.alpha",
title: "Alpha Imported Note",
sourceType: "markdown-vault",
importedAliases: ["Alpha Canon"],
},
body: "# Alpha Imported Note\n\nimported alpha body\n",
}),
"utf8",
);
const titleResult = await getMemoryWikiPage({
config,
lookup: "Alpha Imported Note",
});
expect(titleResult).toMatchObject({
corpus: "wiki",
path: "sources/alpha-import.md",
title: "Alpha Imported Note",
});
const aliasResult = await getMemoryWikiPage({
config,
lookup: "alpha canon",
});
expect(aliasResult).toMatchObject({
corpus: "wiki",
path: "sources/alpha-import.md",
title: "Alpha Imported Note",
});
});
it("falls back to active memory reads when memory corpus is selected", async () => {
const { config } = await createQueryVault({
initialize: true,

View File

@@ -12,7 +12,6 @@ import {
type WikiClaim,
type WikiPageSummary,
} from "./markdown.js";
import { normalizeLookupKey, resolveQueryableWikiPageByLookup } from "./query-lookup.js";
import { initializeMemoryWikiVault } from "./vault.js";
const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
@@ -24,13 +23,9 @@ type QueryDigestPage = {
title: string;
kind: WikiPageSummary["kind"];
path: string;
importRelativePath?: string;
sourceIds: string[];
questions: string[];
contradictions: string[];
importedTags?: string[];
importedAliases?: string[];
importedLinkTargets?: string[];
};
type QueryDigestClaim = {
@@ -199,14 +194,10 @@ function buildPageSearchText(page: QueryableWikiPage): string {
return [
page.title,
page.relativePath,
page.importRelativePath ?? "",
page.id ?? "",
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
page.importedTags.join(" "),
page.importedAliases.join(" "),
page.importedLinkTargets.join(" "),
page.claims.map((claim) => claim.text).join(" "),
page.claims.map((claim) => claim.id ?? "").join(" "),
]
@@ -218,14 +209,10 @@ function buildDigestPageSearchText(page: QueryDigestPage, claims: QueryDigestCla
return [
page.title,
page.path,
page.importRelativePath ?? "",
page.id ?? "",
page.sourceIds.join(" "),
page.questions.join(" "),
page.contradictions.join(" "),
(page.importedTags ?? []).join(" "),
(page.importedAliases ?? []).join(" "),
(page.importedLinkTargets ?? []).join(" "),
claims.map((claim) => claim.text).join(" "),
claims.map((claim) => claim.id ?? "").join(" "),
]
@@ -285,7 +272,6 @@ function buildDigestCandidatePaths(params: {
let score = 1;
const titleLower = page.title.toLowerCase();
const pathLower = page.path.toLowerCase();
const importRelativePathLower = page.importRelativePath?.toLowerCase() ?? "";
const idLower = page.id?.toLowerCase() ?? "";
if (titleLower === queryLower) {
score += 50;
@@ -295,9 +281,6 @@ function buildDigestCandidatePaths(params: {
if (pathLower.includes(queryLower)) {
score += 10;
}
if (importRelativePathLower.includes(queryLower)) {
score += 14;
}
if (idLower.includes(queryLower)) {
score += 20;
}
@@ -391,7 +374,6 @@ function scorePage(page: QueryableWikiPage, query: string): number {
const queryLower = query.toLowerCase();
const titleLower = page.title.toLowerCase();
const pathLower = page.relativePath.toLowerCase();
const importRelativePathLower = page.importRelativePath?.toLowerCase() ?? "";
const idLower = page.id?.toLowerCase() ?? "";
const metadataLower = buildPageSearchText(page).toLowerCase();
const rawLower = page.raw.toLowerCase();
@@ -399,7 +381,6 @@ function scorePage(page: QueryableWikiPage, query: string): number {
!(
titleLower.includes(queryLower) ||
pathLower.includes(queryLower) ||
importRelativePathLower.includes(queryLower) ||
idLower.includes(queryLower) ||
metadataLower.includes(queryLower) ||
rawLower.includes(queryLower)
@@ -417,24 +398,12 @@ function scorePage(page: QueryableWikiPage, query: string): number {
if (pathLower.includes(queryLower)) {
score += 10;
}
if (importRelativePathLower.includes(queryLower)) {
score += 14;
}
if (idLower.includes(queryLower)) {
score += 20;
}
if (page.sourceIds.some((sourceId) => sourceId.toLowerCase().includes(queryLower))) {
score += 12;
}
if (page.importedTags.some((tag) => tag.toLowerCase().includes(queryLower))) {
score += 10;
}
if (page.importedAliases.some((alias) => alias.toLowerCase().includes(queryLower))) {
score += 16;
}
if (page.importedLinkTargets.some((target) => target.toLowerCase().includes(queryLower))) {
score += 8;
}
const matchingClaims = getMatchingClaims(page, queryLower);
if (matchingClaims.length > 0) {
score += rankClaimMatch(page, matchingClaims[0], queryLower);
@@ -445,7 +414,10 @@ function scorePage(page: QueryableWikiPage, query: string): number {
return score;
}
export { resolveQueryableWikiPageByLookup } from "./query-lookup.js";
function normalizeLookupKey(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
}
function buildLookupCandidates(lookup: string): string[] {
const normalized = normalizeLookupKey(lookup);
@@ -631,6 +603,22 @@ function resolveDigestClaimLookup(digest: QueryDigestBundle, lookup: string): st
return match?.pagePath ?? null;
}
export function resolveQueryableWikiPageByLookup(
pages: QueryableWikiPage[],
lookup: string,
): QueryableWikiPage | null {
const key = normalizeLookupKey(lookup);
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
return (
pages.find((page) => page.relativePath === key) ??
pages.find((page) => page.relativePath === withExtension) ??
pages.find((page) => page.relativePath.replace(/\.md$/i, "") === key) ??
pages.find((page) => path.basename(page.relativePath, ".md") === key) ??
pages.find((page) => page.id === key) ??
null
);
}
export async function searchMemoryWiki(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;

View File

@@ -9,29 +9,18 @@ import {
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
export async function writeImportedSourcePage(
params: {
vaultRoot: string;
syncKey: string;
sourcePath: string;
sourceUpdatedAtMs: number;
sourceSize: number;
renderFingerprint: string;
pagePath: string;
group: MemoryWikiImportedSourceGroup;
scopeKey?: string;
state: ImportedSourceState;
} & (
| {
buildRendered: (raw: string, updatedAt: string) => string;
rendered?: never;
}
| {
rendered: string;
buildRendered?: never;
}
),
): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
export async function writeImportedSourcePage(params: {
vaultRoot: string;
syncKey: string;
sourcePath: string;
sourceUpdatedAtMs: number;
sourceSize: number;
renderFingerprint: string;
pagePath: string;
group: MemoryWikiImportedSourceGroup;
state: ImportedSourceState;
buildRendered: (raw: string, updatedAt: string) => string;
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
const pageAbsPath = path.join(params.vaultRoot, params.pagePath);
const created = !(await pathExists(pageAbsPath));
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
@@ -49,10 +38,8 @@ export async function writeImportedSourcePage(
return { pagePath: params.pagePath, changed: false, created };
}
const rendered =
"rendered" in params
? params.rendered
: params.buildRendered(await fs.readFile(params.sourcePath, "utf8"), updatedAt);
const raw = await fs.readFile(params.sourcePath, "utf8");
const rendered = params.buildRendered(raw, updatedAt);
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
if (existing !== rendered) {
await fs.writeFile(pageAbsPath, rendered, "utf8");
@@ -63,7 +50,6 @@ export async function writeImportedSourcePage(
state: params.state,
entry: {
group: params.group,
...(params.scopeKey ? { scopeKey: params.scopeKey } : {}),
pagePath: params.pagePath,
sourcePath: params.sourcePath,
sourceUpdatedAtMs: params.sourceUpdatedAtMs,

View File

@@ -1,11 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local" | "import";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local";
export type MemoryWikiImportedSourceStateEntry = {
group: MemoryWikiImportedSourceGroup;
scopeKey?: string;
pagePath: string;
sourcePath: string;
sourceUpdatedAtMs: number;
@@ -101,15 +100,10 @@ export async function pruneImportedSourceEntries(params: {
group: MemoryWikiImportedSourceGroup;
activeKeys: Set<string>;
state: MemoryWikiImportedSourceState;
scopeKey?: string;
}): Promise<number> {
let removedCount = 0;
for (const [syncKey, entry] of Object.entries(params.state.entries)) {
if (
entry.group !== params.group ||
params.activeKeys.has(syncKey) ||
(params.scopeKey !== undefined && entry.scopeKey !== params.scopeKey)
) {
if (entry.group !== params.group || params.activeKeys.has(syncKey)) {
continue;
}
const pageAbsPath = path.join(params.vaultRoot, entry.pagePath);

View File

@@ -68,6 +68,9 @@
"videoGenerationProviders": ["minimax"],
"webSearchProviders": ["minimax"]
},
"configContracts": {
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
},
"uiHints": {
"webSearch.apiKey": {
"label": "MiniMax Coding Plan key",

View File

@@ -1,4 +1,3 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
@@ -8,7 +7,7 @@ import {
applyAuthorizationHeaderForUrl,
GRAPH_ROOT,
inferPlaceholder,
isRecord,
readNestedString,
isUrlAllowed,
type MSTeamsAttachmentFetchPolicy,
normalizeContentType,
@@ -39,17 +38,6 @@ type GraphAttachment = {
content?: unknown;
};
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
let current: unknown = value;
for (const key of keys) {
if (!isRecord(current)) {
return undefined;
}
current = current[key as keyof typeof current];
}
return normalizeOptionalString(current);
}
export function buildMSTeamsGraphMessageUrls(params: {
conversationType?: string | null;
conversationId?: string | null;

View File

@@ -7,7 +7,7 @@ import {
normalizeHostnameSuffixAllowlist,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-policy";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
@@ -78,6 +78,17 @@ export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
export { isRecord };
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
let current: unknown = value;
for (const key of keys) {
if (!isRecord(current)) {
return undefined;
}
current = current[key as keyof typeof current];
}
return normalizeOptionalString(current);
}
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;

View File

@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { isRecord } from "./attachments/shared.js";
import { isRecord, readNestedString } from "./attachments/shared.js";
import { resolveMSTeamsStorePath } from "./storage.js";
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
@@ -88,11 +87,6 @@ function readNestedValue(value: unknown, keys: Array<string | number>): unknown
return current;
}
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
const found = readNestedValue(value, keys);
return normalizeOptionalString(found);
}
export function extractMSTeamsPollVote(
activity: { value?: unknown } | undefined,
): MSTeamsPollVote | null {

View File

@@ -11,7 +11,7 @@ import { normalizeResolvedSecretInputString } from "./secret-input.js";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
function isTruthyEnvValue(value?: string): boolean {
const normalized = (value ?? "").trim().toLowerCase();
const normalized = normalizeOptionalString(value)?.toLowerCase() ?? "";
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
}

View File

@@ -12,11 +12,14 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin {
liveTest: {
defaultModelRef: CODEX_CLI_DEFAULT_MODEL_REF,
defaultImageProbe: true,
defaultMcpProbe: true,
docker: {
npmPackage: "@openai/codex",
binaryName: "codex",
},
},
bundleMcp: true,
bundleMcpMode: "codex-config-overrides",
config: {
command: "codex",
args: [

View File

@@ -1,7 +1,3 @@
export function trimNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
export const trimNonEmptyString = normalizeOptionalString;

View File

@@ -1,4 +1,4 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-entry";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
export function normalizeConfig(params: { provider: string; providerConfig: ModelProviderConfig }) {
return params.providerConfig;

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
definePluginEntry,
type OpenClawPluginApi,
@@ -259,7 +260,7 @@ function formatHelp(): string {
}
function parseGroup(raw: string | undefined): ArmGroup | null {
const value = (raw ?? "").trim().toLowerCase();
const value = normalizeOptionalString(raw)?.toLowerCase() ?? "";
if (!value) {
return null;
}

View File

@@ -0,0 +1 @@
export * from "./src/model-selection.js";

View File

@@ -1,4 +1,4 @@
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../src/model-selection.js";
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js";
import { formatErrorMessage } from "./errors.js";
import {
type Bootstrap,

View File

@@ -7,7 +7,10 @@ import {
createChannelNativeOriginTargetResolver,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { listSlackAccountIds } from "./accounts.js";
import { isSlackApprovalAuthorizedSender } from "./approval-auth.js";
import {
@@ -46,7 +49,7 @@ function normalizeSlackThreadMatchKey(threadId?: string): string {
}
function resolveTurnSourceSlackOriginTarget(request: ApprovalRequest): SlackOriginTarget | null {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
if (turnSourceChannel !== "slack" || !turnSourceTo) {
return null;

View File

@@ -67,13 +67,6 @@ function extractScopes(payload: unknown): string[] {
return normalizeScopes(scopes);
}
function readError(payload: unknown): string | undefined {
if (!isRecord(payload)) {
return undefined;
}
return normalizeOptionalString(payload.error);
}
async function callSlack(
client: WebClient,
method: SlackScopesSource,
@@ -103,7 +96,7 @@ export async function fetchSlackScopes(
if (scopes.length > 0) {
return { ok: true, scopes, source: method };
}
const error = readError(result);
const error = isRecord(result) ? normalizeOptionalString(result.error) : undefined;
if (error) {
errors.push(`${method}: ${error}`);
}

View File

@@ -9,6 +9,7 @@ export {
isTtsProviderConfigured,
listSpeechVoices,
maybeApplyTtsToPayload,
resolveExplicitTtsOverrides,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,

View File

@@ -25,8 +25,8 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
import {
CONFIG_DIR,
normalizeOptionalString,
resolveConfigDir,
resolveUserPath,
stripMarkdown,
} from "openclaw/plugin-sdk/text-runtime";
@@ -41,6 +41,7 @@ import {
summarizeText,
type SpeechModelOverridePolicy,
type SpeechProviderConfig,
type SpeechProviderOverrides,
type SpeechVoiceOption,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
@@ -173,7 +174,7 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
if (envPath) {
return resolveUserPath(envPath);
}
return path.join(CONFIG_DIR, "settings", "tts.json");
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
}
function resolveModelOverridePolicy(
@@ -327,7 +328,9 @@ export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig {
mode: raw.mode ?? "final",
provider:
normalizeConfiguredSpeechProviderId(raw.provider) ??
(providerSource === "config" ? raw.provider?.trim().toLowerCase() || "" : ""),
(providerSource === "config"
? (normalizeOptionalString(raw.provider)?.toLowerCase() ?? "")
: ""),
providerSource,
summaryModel: normalizeOptionalString(raw.summaryModel),
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
@@ -500,6 +503,66 @@ export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
});
}
export function resolveExplicitTtsOverrides(params: {
cfg: OpenClawConfig;
prefsPath?: string;
provider?: string;
modelId?: string;
voiceId?: string;
}): TtsDirectiveOverrides {
const providerInput = params.provider?.trim();
const modelId = params.modelId?.trim();
const voiceId = params.voiceId?.trim();
const config = resolveTtsConfig(params.cfg);
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
const selectedProvider =
canonicalizeSpeechProviderId(providerInput, params.cfg) ??
(modelId || voiceId ? getTtsProvider(config, prefsPath) : undefined);
if (providerInput && !selectedProvider) {
throw new Error(`Unknown TTS provider "${providerInput}".`);
}
if (!modelId && !voiceId) {
return selectedProvider ? { provider: selectedProvider } : {};
}
if (!selectedProvider) {
throw new Error("TTS model or voice overrides require a resolved provider.");
}
const provider = getSpeechProvider(selectedProvider, params.cfg);
if (!provider) {
throw new Error(`speech provider ${selectedProvider} is not registered`);
}
if (!provider.resolveTalkOverrides) {
throw new Error(
`TTS provider "${selectedProvider}" does not support model or voice overrides.`,
);
}
const providerOverrides = provider.resolveTalkOverrides({
talkProviderConfig: {},
params: {
...(voiceId ? { voiceId } : {}),
...(modelId ? { modelId } : {}),
},
});
if ((voiceId || modelId) && (!providerOverrides || Object.keys(providerOverrides).length === 0)) {
throw new Error(
`TTS provider "${selectedProvider}" ignored the requested model or voice overrides.`,
);
}
const overridesRecord = providerOverrides as SpeechProviderOverrides;
return {
provider: selectedProvider,
providerOverrides: {
[provider.id]: overridesRecord,
},
};
}
export function getTtsMaxLength(prefsPath: string): number {
const prefs = readPrefs(prefsPath);
return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;

View File

@@ -1,6 +1,7 @@
import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
function mask(s: string, keep: number = 6): string {
@@ -79,11 +80,15 @@ function findVoice(voices: SpeechVoiceOption[], query: string): SpeechVoiceOptio
if (byId) {
return byId;
}
const exactName = voices.find((v) => (v.name ?? "").trim().toLowerCase() === lower);
const exactName = voices.find(
(v) => (normalizeOptionalString(v.name)?.toLowerCase() ?? "") === lower,
);
if (exactName) {
return exactName;
}
const partial = voices.find((v) => (v.name ?? "").trim().toLowerCase().includes(lower));
const partial = voices.find((v) =>
(normalizeOptionalString(v.name)?.toLowerCase() ?? "").includes(lower),
);
return partial ?? null;
}

View File

@@ -8,7 +8,10 @@ import {
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { listTelegramAccountIds } from "./accounts.js";
import {
getTelegramExecApprovalApprovers,
@@ -27,7 +30,7 @@ type TelegramOriginTarget = { to: string; threadId?: number };
function resolveTurnSourceTelegramOriginTarget(
request: ApprovalRequest,
): TelegramOriginTarget | null {
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const parsedTurnSourceTarget = rawTurnSourceTo ? parseTelegramTarget(rawTurnSourceTo) : null;
const turnSourceTo = normalizeTelegramChatId(parsedTurnSourceTarget?.chatId ?? rawTurnSourceTo);

View File

@@ -1,3 +1,5 @@
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
export type TelegramCustomCommandInput = {
@@ -17,7 +19,7 @@ export function normalizeTelegramCommandName(value: string): string {
return "";
}
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
return withoutSlash.trim().toLowerCase().replace(/-/g, "_");
return (normalizeOptionalLowercaseString(withoutSlash) ?? "").replace(/-/g, "_");
}
export function normalizeTelegramCommandDescription(value: string): string {

View File

@@ -11,6 +11,7 @@ import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-runt
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramInlineButtonsConfigScope } from "./inline-buttons.js";
import { normalizeTelegramChatId, resolveTelegramTargetChatType } from "./targets.js";
@@ -107,7 +108,9 @@ function matchesTelegramRequestAccount(params: {
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,

View File

@@ -114,11 +114,6 @@ const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
"compacting",
];
function normalizeEmoji(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value);
return trimmed ? trimmed : undefined;
}
function toUniqueNonEmpty(values: string[]): string[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
}
@@ -128,18 +123,18 @@ export function resolveTelegramStatusReactionEmojis(params: {
overrides?: StatusReactionEmojis;
}): Required<StatusReactionEmojis> {
const { overrides } = params;
const queuedFallback = normalizeEmoji(params.initialEmoji) ?? DEFAULT_EMOJIS.queued;
const queuedFallback = normalizeOptionalString(params.initialEmoji) ?? DEFAULT_EMOJIS.queued;
return {
queued: normalizeEmoji(overrides?.queued) ?? queuedFallback,
thinking: normalizeEmoji(overrides?.thinking) ?? DEFAULT_EMOJIS.thinking,
tool: normalizeEmoji(overrides?.tool) ?? DEFAULT_EMOJIS.tool,
coding: normalizeEmoji(overrides?.coding) ?? DEFAULT_EMOJIS.coding,
web: normalizeEmoji(overrides?.web) ?? DEFAULT_EMOJIS.web,
done: normalizeEmoji(overrides?.done) ?? DEFAULT_EMOJIS.done,
error: normalizeEmoji(overrides?.error) ?? DEFAULT_EMOJIS.error,
stallSoft: normalizeEmoji(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,
stallHard: normalizeEmoji(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard,
compacting: normalizeEmoji(overrides?.compacting) ?? DEFAULT_EMOJIS.compacting,
queued: normalizeOptionalString(overrides?.queued) ?? queuedFallback,
thinking: normalizeOptionalString(overrides?.thinking) ?? DEFAULT_EMOJIS.thinking,
tool: normalizeOptionalString(overrides?.tool) ?? DEFAULT_EMOJIS.tool,
coding: normalizeOptionalString(overrides?.coding) ?? DEFAULT_EMOJIS.coding,
web: normalizeOptionalString(overrides?.web) ?? DEFAULT_EMOJIS.web,
done: normalizeOptionalString(overrides?.done) ?? DEFAULT_EMOJIS.done,
error: normalizeOptionalString(overrides?.error) ?? DEFAULT_EMOJIS.error,
stallSoft: normalizeOptionalString(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,
stallHard: normalizeOptionalString(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard,
compacting: normalizeOptionalString(overrides?.compacting) ?? DEFAULT_EMOJIS.compacting,
};
}
@@ -148,7 +143,7 @@ export function buildTelegramStatusReactionVariants(
): Map<string, string[]> {
const variantsByRequested = new Map<string, string[]>();
for (const key of STATUS_REACTION_EMOJI_KEYS) {
const requested = normalizeEmoji(emojis[key]);
const requested = normalizeOptionalString(emojis[key]);
if (!requested) {
continue;
}
@@ -225,7 +220,7 @@ export function resolveTelegramReactionVariant(params: {
variantsByRequestedEmoji: Map<string, string[]>;
allowedEmojiReactions?: Set<TelegramReactionEmoji> | null;
}): TelegramReactionEmoji | undefined {
const requestedEmoji = normalizeEmoji(params.requestedEmoji);
const requestedEmoji = normalizeOptionalString(params.requestedEmoji);
if (!requestedEmoji) {
return undefined;
}

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