Compare commits

..

16 Commits

Author SHA1 Message Date
Vincent Koc
162dfdd593 fix(memory-wiki): skip empty chatgpt conversations 2026-04-07 10:06:20 +01:00
Vincent Koc
5b9e4d93f7 fix(memory-wiki): skip hidden chatgpt noise 2026-04-07 10:06:20 +01:00
Vincent Koc
debfc3e267 fix(memory-wiki): preserve chatgpt turn order 2026-04-07 10:06:20 +01:00
Vincent Koc
bb9b9de146 fix(memory-wiki): extract rich chatgpt content 2026-04-07 10:06:20 +01:00
Vincent Koc
3c37501544 fix(memory-wiki): honor current chatgpt branch 2026-04-07 10:06:20 +01:00
Vincent Koc
f0dfaad99e feat(memory-wiki): import chatgpt export conversations 2026-04-07 10:06:20 +01:00
Vincent Koc
c21971f51c feat(memory-wiki): detect chatgpt export placeholders 2026-04-07 10:06:20 +01:00
Vincent Koc
615a1f0e1f feat(memory-wiki): preserve imported relative paths 2026-04-07 10:06:20 +01:00
Vincent Koc
bc3c3e40e8 feat(memory-wiki): detect duplicate imported note bodies 2026-04-07 10:06:20 +01:00
Vincent Koc
69bc4c697a feat(memory-wiki): flag duplicate and low-signal imports 2026-04-07 10:06:20 +01:00
Vincent Koc
1f52db4230 feat(memory-wiki): resolve imported aliases in lookup 2026-04-07 10:06:20 +01:00
Vincent Koc
7522ae057c feat(memory-wiki): reconnect imported vault related links 2026-04-07 10:06:19 +01:00
Vincent Koc
0e063984fd feat(memory-wiki): use imported vault metadata in retrieval 2026-04-07 10:06:19 +01:00
Vincent Koc
2d0ddac97c feat(memory-wiki): surface import task tracking 2026-04-07 10:06:19 +01:00
Vincent Koc
2b1df6a73e feat(memory-wiki): preserve markdown vault note structure 2026-04-07 10:06:19 +01:00
Vincent Koc
c21cf08013 feat(memory-wiki): add task-backed wiki import 2026-04-07 10:06:19 +01:00
390 changed files with 5211 additions and 10229 deletions

View File

@@ -753,11 +753,6 @@ 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
@@ -802,7 +797,6 @@ 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 }}
@@ -826,7 +820,6 @@ 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,7 +6,6 @@ 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.
@@ -24,12 +23,20 @@ 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.
@@ -39,8 +46,12 @@ 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)
@@ -54,7 +65,6 @@ 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.
@@ -252,7 +262,6 @@ 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,8 +537,6 @@ 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
@@ -568,8 +566,6 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -598,8 +594,6 @@ 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
@@ -630,8 +624,6 @@ 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,8 +537,6 @@ 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
@@ -568,8 +566,6 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -598,8 +594,6 @@ 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
@@ -630,8 +624,6 @@ 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 @@
bcf997afb562b69552d0bf772ccad85b48df14cc3f314fdd5265644702fdfd2d plugin-sdk-api-baseline.json
fcef1106262e6d0f53d67d1f1e968b34cd49ed45c89972364fe6c7d9ffcf1f5b plugin-sdk-api-baseline.jsonl
3d483bffbe5abb831df3b1efdf40e1ae0d22d644853a7629ecdaa6d535386ee6 plugin-sdk-api-baseline.json
eebeff7cc3ca490d3cae268ea97c5968f37f50fe1a9c7eabeeab85a4ae66a9d9 plugin-sdk-api-baseline.jsonl

View File

@@ -1,119 +0,0 @@
---
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,7 +35,6 @@ 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)
@@ -249,16 +248,6 @@ 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-flash-preview`
- Default model: `google-gemini-cli/gemini-3.1-pro-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,10 +214,8 @@ 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: ["--output-format", "json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
- `imageArg: "@"`
- `imagePathScope: "workspace"`
- `args: ["--prompt", "--output-format", "json"]`
- `resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"]`
- `modelArg: "--model"`
- `sessionMode: "existing"`
- `sessionIdFields: ["session_id", "sessionId"]`
@@ -253,9 +251,8 @@ opt into a generated MCP config overlay with `bundleMcp: true`.
Current bundled behavior:
- `claude-cli`: generated strict MCP config file
- `codex-cli`: inline config overrides for `mcp_servers`
- `google-gemini-cli`: generated Gemini system settings file
- `codex-cli`: no bundle MCP overlay
- `google-gemini-cli`: no bundle MCP overlay
When bundle MCP is enabled, OpenClaw:
@@ -263,8 +260,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/settings shape
- rewrites the launch config using the backend-owned integration mode from the owning extension
- merges them with any existing backend `--mcp-config`
- rewrites the CLI args to pass `--strict-mcp-config --mcp-config <generated-file>`
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-flash-preview`
4. Default model after login: `google-gemini-cli/gemini-3.1-pro-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,7 +300,6 @@ 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-flash-preview`
- Default model: `google-gemini-cli/gemini-3.1-pro-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-flash-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.1-pro-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.2"
"acpx": "0.5.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -45,11 +45,7 @@ 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;
discardPersistentState?: boolean;
}): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
}
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;

View File

@@ -1,37 +1,74 @@
import type { AcpSessionStore } from "acpx/runtime";
import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpRuntime } from "../runtime-api.js";
import { AcpxRuntime } from "./runtime.js";
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",
});
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() {}
}
return {
runtime,
wrappedStore: (
runtime as unknown as {
sessionStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
}
).sessionStore,
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
state,
MockAcpxRuntime,
};
}
});
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(() => {
vi.restoreAllMocks();
mocks.state.capturedStore = undefined;
});
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
@@ -40,9 +77,20 @@ describe("AcpxRuntime fresh reset wrapper", () => {
save: vi.fn(async () => {}),
};
const { runtime, wrappedStore } = makeRuntime(baseStore);
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
const wrappedStore = mocks.state.capturedStore;
expect(wrappedStore).toBeDefined();
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
acpxRecordId: "stale",
});
expect(baseStore.load).toHaveBeenCalledTimes(1);
@@ -51,17 +99,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);
@@ -73,8 +121,18 @@ describe("AcpxRuntime fresh reset wrapper", () => {
save: vi.fn(async () => {}),
};
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
const runtime = new AcpxRuntime({
cwd: "/tmp",
sessionStore: baseStore,
agentRegistry: {
resolve: () => "codex",
list: () => ["codex"],
},
permissionMode: "approve-reads",
});
const wrappedStore = mocks.state.capturedStore;
expect(wrappedStore).toBeDefined();
await runtime.close({
handle: {
@@ -86,16 +144,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
discardPersistentState: true,
});
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(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
expect(baseStore.load).not.toHaveBeenCalled();
});
});

View File

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

View File

@@ -19,14 +19,12 @@ 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,5 +1,4 @@
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`;
@@ -93,7 +92,7 @@ const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
const CLAUDE_SAFE_SETTING_SOURCES = "user";
export function isClaudeCliProvider(providerId: string): boolean {
return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID;
return providerId.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {

View File

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

View File

@@ -1,4 +1,3 @@
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";
@@ -27,11 +26,11 @@ export function resolveBrowserControlAuth(
}
function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
const nodeEnv = normalizeLowercaseStringOrEmpty(env.NODE_ENV);
const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
if (nodeEnv === "test") {
return false;
}
const vitest = normalizeLowercaseStringOrEmpty(env.VITEST);
const vitest = (env.VITEST ?? "").trim().toLowerCase();
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
return false;
}

View File

@@ -1,5 +1,4 @@
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 {
@@ -36,7 +35,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 = normalizeLowercaseStringOrEmpty(params.secFetchSite);
const secFetchSite = (params.secFetchSite ?? "").trim().toLowerCase();
if (secFetchSite === "cross-site") {
return true;
}

View File

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

View File

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

View File

@@ -475,10 +475,6 @@ 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: normalizeOptionalString(context.agentId),
sessionId: normalizeOptionalString(context.sessionId),
messageChannel: normalizeOptionalString(context.messageChannel),
agentAccountId: normalizeOptionalString(context.agentAccountId),
agentId: normalizeContextString(context.agentId),
sessionId: normalizeContextString(context.sessionId),
messageChannel: normalizeContextString(context.messageChannel),
agentAccountId: normalizeContextString(context.agentAccountId),
};
return Object.values(artifactContext).some((value) => value !== undefined)
@@ -439,6 +439,11 @@ 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,6 +1,5 @@
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,
@@ -104,7 +103,7 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC
}),
resolveTurnSourceTarget: (request) => {
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);

View File

@@ -2,7 +2,6 @@ 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";
@@ -16,7 +15,7 @@ type DiscordChannel = { id: string; name?: string | null };
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
function normalizeQuery(value?: string | null): string {
return normalizeOptionalLowercaseString(value) ?? "";
return value?.trim().toLowerCase() ?? "";
}
function buildUserRank(user: DiscordUser): number {

View File

@@ -30,7 +30,6 @@ 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,
@@ -513,11 +512,6 @@ 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";
@@ -560,7 +554,7 @@ async function dispatchDiscordComponentEvent(params: {
onReplyStart: async () => {
try {
const { sendTyping } = await loadTypingRuntime();
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
await sendTyping({ client: interaction.client, channelId: typingChannelId });
} catch (err) {
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
}

View File

@@ -158,9 +158,6 @@ 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",
@@ -217,7 +214,6 @@ beforeEach(() => {
recordInboundSession.mockClear();
readSessionUpdatedAt.mockClear();
resolveStorePath.mockClear();
createDiscordRestClientSpy.mockClear();
dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult());
recordInboundSession.mockResolvedValue(undefined);
readSessionUpdatedAt.mockReturnValue(undefined);
@@ -282,7 +278,7 @@ function expectAckReactionRuntimeOptions(params?: {
messages.removeAckAfterReply = params.removeAckAfterReply;
}
return expect.objectContaining({
rest: expect.anything(),
rest: {},
...(Object.keys(messages).length > 0
? { cfg: expect.objectContaining({ messages: expect.objectContaining(messages) }) }
: {}),
@@ -341,7 +337,7 @@ function expectSinglePreviewEdit() {
"c1",
"preview-1",
{ content: "Hello\nWorld" },
expect.objectContaining({ rest: expect.anything() }),
{ rest: {} },
);
expect(deliverDiscordReply).not.toHaveBeenCalled();
}
@@ -401,39 +397,6 @@ 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?.();
@@ -770,7 +733,7 @@ describe("processDiscordMessage draft streaming", () => {
"c1",
"preview-1",
{ content: longReply },
expect.objectContaining({ rest: expect.anything() }),
{ rest: {} },
);
expect(deliverDiscordReply).not.toHaveBeenCalled();
});

View File

@@ -45,7 +45,6 @@ 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";
@@ -210,19 +209,9 @@ 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: feedbackRest,
rest: client.rest as unknown as RequestClient,
cfg,
accountId,
});
@@ -533,7 +522,7 @@ export async function processDiscordMessage(
channel: "discord",
accountId: route.accountId,
typing: {
start: () => sendTyping({ rest: feedbackRest, channelId: typingChannelId }),
start: () => sendTyping({ client, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
@@ -571,7 +560,7 @@ export async function processDiscordMessage(
: messageChannelId;
const draftStream = canStreamDraft
? createDiscordDraftStream({
rest: deliveryRest,
rest: client.rest,
channelId: deliverChannelId,
maxChars: draftMaxChars,
replyToMessageId: draftReplyToMessageId,
@@ -757,7 +746,7 @@ export async function processDiscordMessage(
deliverChannelId,
previewMessageId,
{ content: previewFinalText },
{ rest: deliveryRest },
{ rest: client.rest },
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
@@ -790,7 +779,7 @@ export async function processDiscordMessage(
deliverChannelId,
messageIdAfterStop,
{ content: previewFinalText },
{ rest: deliveryRest },
{ rest: client.rest },
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
@@ -823,7 +812,7 @@ export async function processDiscordMessage(
target: deliverTarget,
token,
accountId,
rest: deliveryRest,
rest: client.rest,
runtime,
replyToId,
replyToMode,

View File

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

View File

@@ -282,10 +282,9 @@ async function expectPairCommandReply(params: {
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(params.interaction.followUp).toHaveBeenCalledWith(
expect(params.interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({ content: "paired:now" }),
);
expect(params.interaction.reply).not.toHaveBeenCalled();
}
async function createStatusCommand(cfg: OpenClawConfig) {
@@ -466,13 +465,12 @@ describe("Discord native plugin command dispatch", () => {
expect(executeSpy).not.toHaveBeenCalled();
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect(interaction.reply).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 () => {
@@ -503,12 +501,11 @@ describe("Discord native plugin command dispatch", () => {
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect(interaction.reply).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 () => {
@@ -543,10 +540,9 @@ describe("Discord native plugin command dispatch", () => {
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.followUp).toHaveBeenCalledWith(
expect(interaction.reply).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,9 +715,7 @@ export function createDiscordNativeCommand(params: {
discordConfig,
accountId,
sessionPrefix,
// Slash commands are deferred up front, so all later responses must use
// follow-up/edit semantics instead of the initial reply endpoint.
preferFollowUp: true,
preferFollowUp: false,
threadBindings,
});
}

View File

@@ -3,7 +3,6 @@ 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,
@@ -113,7 +112,14 @@ export function normalizeTargetKind(
}
export function normalizeThreadId(raw: unknown): string | undefined {
return normalizeOptionalStringifiedId(raw);
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;
}
export function toBindingRecordKey(params: { accountId: string; threadId: string }): string {

View File

@@ -1,42 +0,0 @@
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,23 +1,11 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import type { Client } from "@buape/carbon";
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);
}
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();
}
}

View File

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

View File

@@ -1,4 +1,3 @@
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";
@@ -10,7 +9,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(normalizeOptionalLowercaseString(eventKey) ?? "");
return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase());
}
export function createQuickActionLauncherCard(params: {

View File

@@ -1,4 +1,3 @@
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";
@@ -38,9 +37,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({
@@ -91,9 +90,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-flash-preview";
const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3.1-pro-preview";
export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
return {
@@ -17,22 +17,17 @@ 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,7 +3,6 @@ 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";
@@ -55,7 +54,7 @@ function cloneGoogleTemplateModel(params: {
}
function isGoogleGeminiCliProvider(providerId: string): boolean {
return normalizeOptionalLowercaseString(providerId) === GOOGLE_GEMINI_CLI_PROVIDER_ID;
return providerId.trim().toLowerCase() === GOOGLE_GEMINI_CLI_PROVIDER_ID;
}
function templateIdsForProvider(

View File

@@ -3,7 +3,7 @@ import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } 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 = normalizeOptionalLowercaseString(value);
const normalized = normalizeOptionalString(value)?.toLowerCase();
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,20 +45,10 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
describe("huggingface plugin", () => {
it("skips catalog discovery when plugin discovery is disabled", async () => {
const provider = registerProvider();
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
const result = await provider.catalog.run({
config: {
plugins: {
entries: {
huggingface: {
config: {
discovery: { enabled: false },
},
},
},
},
},
config: {},
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 { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import { setIrcRuntime } from "./runtime.js";
import {
ircSetupAdapter,
parsePort,
@@ -82,7 +82,6 @@ function installIrcRuntime() {
describe("irc setup", () => {
afterEach(() => {
vi.clearAllMocks();
clearIrcRuntime();
});
it("parses valid ports and falls back for invalid values", () => {
@@ -405,11 +404,13 @@ 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: ircPlugin.gateway!.startAccount!,
startAccount: runtimeMockedPlugin.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 { ResolvedLineAccount } from "./types.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
type EventSource = webhook.Source | undefined;
type MessageEvent = webhook.MessageEvent;
@@ -283,6 +283,17 @@ 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;
@@ -369,12 +380,7 @@ async function finalizeLineInboundContext(params: {
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
GroupSystemPrompt: params.source.isGroup
? normalizeOptionalString(
resolveLineGroupConfigEntry(params.account.config.groups, {
groupId: params.source.groupId,
roomId: params.source.roomId,
})?.systemPrompt,
)
? resolveLineGroupSystemPrompt(params.account.config.groups, params.source)
: 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 { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -25,7 +25,6 @@ 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", () => ({
@@ -93,13 +92,8 @@ vi.mock("./template-messages.js", () => ({
}));
describe("monitorLineProvider lifecycle", () => {
beforeAll(async () => {
({ monitorLineProvider, getLineRuntimeState, clearLineRuntimeStateForTests } =
await import("./monitor.js"));
});
beforeEach(() => {
clearLineRuntimeStateForTests();
beforeEach(async () => {
vi.resetModules();
createLineBotMock.mockReset();
createLineBotMock.mockReturnValue({
account: { accountId: "default" },
@@ -111,6 +105,7 @@ describe("monitorLineProvider lifecycle", () => {
.mockImplementation(() => innerLineWebhookHandlerMock);
unregisterHttpMock.mockReset();
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
({ monitorLineProvider, getLineRuntimeState } = await import("./monitor.js"));
});
const createRouteResponse = () => {

View File

@@ -97,10 +97,6 @@ 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,11 +8,7 @@ import {
createChannelNativeOriginTargetResolver,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import {
getMatrixExecApprovalApprovers,
@@ -55,8 +51,13 @@ 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 = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
const target = resolveMatrixNativeTarget(turnSourceTo);
if (turnSourceChannel !== "matrix" || !target) {
@@ -64,7 +65,7 @@ function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOr
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(request.request.turnSourceThreadId),
threadId: normalizeThreadId(request.request.turnSourceThreadId),
};
}
@@ -78,7 +79,7 @@ function resolveSessionMatrixOriginTarget(sessionTarget: {
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionTarget.threadId),
threadId: normalizeThreadId(sessionTarget.threadId),
};
}

View File

@@ -12,7 +12,6 @@ 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,
@@ -96,6 +95,11 @@ 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;
@@ -286,7 +290,7 @@ export class MatrixExecApprovalHandler {
if (!target) {
return null;
}
const threadId = normalizeOptionalStringifiedId(rawTarget.threadId);
const threadId = normalizeThreadId(rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: this.opts.cfg as CoreConfig,

View File

@@ -11,7 +11,6 @@ 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";
@@ -83,9 +82,7 @@ function matchesMatrixRequestAccount(params: {
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,

View File

@@ -1,4 +1,3 @@
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";
@@ -13,7 +12,11 @@ function normalizeTarget(raw: string): string {
}
export function normalizeThreadId(raw?: string | number | null): string | null {
return normalizeOptionalStringifiedId(raw) ?? null;
if (raw === undefined || raw === null) {
return null;
}
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
// Size-capped to prevent unbounded growth (#4948)

View File

@@ -1,4 +1,3 @@
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 {
@@ -9,7 +8,7 @@ import type {
} from "./runtime-api.js";
function normalizeLookupQuery(query: string): string {
return normalizeOptionalLowercaseString(query) ?? "";
return query.trim().toLowerCase();
}
function findExactDirectoryMatches(
@@ -21,9 +20,9 @@ function findExactDirectoryMatches(
return [];
}
return matches.filter((match) => {
const id = normalizeOptionalLowercaseString(match.id);
const name = normalizeOptionalLowercaseString(match.name);
const handle = normalizeOptionalLowercaseString(match.handle);
const id = match.id.trim().toLowerCase();
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
return normalized === id || normalized === name || normalized === handle;
});
}

View File

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

View File

@@ -4,9 +4,7 @@ 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,7 +105,8 @@ When `render.createDashboards` is enabled, compile also maintains report dashboa
openclaw wiki status
openclaw wiki doctor
openclaw wiki init
openclaw wiki ingest ./notes/alpha.md
openclaw wiki import ./notes/alpha.md
openclaw wiki import ~/Documents/KnowledgeVault
openclaw wiki compile
openclaw wiki lint
openclaw wiki search "alpha"
@@ -130,6 +131,16 @@ 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`
@@ -159,6 +170,7 @@ Write methods:
- `wiki.init`
- `wiki.compile`
- `wiki.import`
- `wiki.ingest`
- `wiki.lint`
- `wiki.bridge.import`

View File

@@ -24,6 +24,7 @@ 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 ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
- Use `openclaw wiki import`, `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,4 +162,58 @@ 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,6 +77,50 @@ 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 { ingestMemoryWikiSource } from "./ingest.js";
import { importMemoryWikiInput, WIKI_IMPORT_PROFILE_IDS } from "./import.js";
import { lintMemoryWikiVault } from "./lint.js";
import {
probeObsidianCli,
@@ -54,6 +54,11 @@ type WikiIngestCommandOptions = {
title?: string;
};
type WikiImportCommandOptions = {
json?: boolean;
profile?: string;
};
type WikiSearchCommandOptions = {
json?: boolean;
maxResults?: number;
@@ -185,6 +190,17 @@ 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">;
@@ -331,13 +347,30 @@ export async function runWikiIngest(params: {
json: params.json,
stdout: params.stdout,
run: () =>
ingestMemoryWikiSource({
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({
config: params.config,
inputPath: params.inputPath,
profileId: params.profileId,
title: params.title,
}),
render: (value) =>
`Ingested ${value.sourcePath} into ${value.pagePath}. Refreshed ${value.indexUpdatedFiles.length} index file${value.indexUpdatedFiles.length === 1 ? "" : "s"}.`,
render: formatWikiImportSummary,
});
}
@@ -642,6 +675,16 @@ 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,6 +85,54 @@ 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(),
@@ -347,4 +395,47 @@ 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,13 +369,24 @@ 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[];
@@ -418,7 +429,7 @@ function buildRelatedBlockBody(params: {
if (candidate.sourceIds.includes(params.page.id ?? "")) {
return true;
}
return candidate.linkTargets.some((target) =>
return getPageReferenceTargets(candidate).some((target) =>
backlinkKeys.has(normalizeComparableTarget(target)),
);
}),
@@ -706,9 +717,13 @@ 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;
@@ -841,9 +856,15 @@ 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,6 +5,7 @@ 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";
@@ -24,6 +25,10 @@ vi.mock("./ingest.js", () => ({
ingestMemoryWikiSource: vi.fn(),
}));
vi.mock("./import.js", () => ({
importMemoryWikiInput: vi.fn(),
}));
vi.mock("./lint.js", () => ({
lintMemoryWikiVault: vi.fn(),
}));
@@ -90,6 +95,10 @@ 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",
@@ -191,6 +200,47 @@ 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,6 +7,7 @@ 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 {
@@ -156,6 +157,34 @@ 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

@@ -0,0 +1,340 @@
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

@@ -0,0 +1,311 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,54 @@
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

@@ -0,0 +1,92 @@
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

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,302 @@
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

@@ -0,0 +1,905 @@
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,10 +46,14 @@ 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;
@@ -261,6 +265,9 @@ 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)
@@ -269,6 +276,7 @@ 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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,47 @@
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,6 +153,49 @@ 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,
@@ -529,6 +572,46 @@ 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,6 +12,7 @@ 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;
@@ -23,9 +24,13 @@ 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 = {
@@ -194,10 +199,14 @@ 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(" "),
]
@@ -209,10 +218,14 @@ 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(" "),
]
@@ -272,6 +285,7 @@ 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;
@@ -281,6 +295,9 @@ function buildDigestCandidatePaths(params: {
if (pathLower.includes(queryLower)) {
score += 10;
}
if (importRelativePathLower.includes(queryLower)) {
score += 14;
}
if (idLower.includes(queryLower)) {
score += 20;
}
@@ -374,6 +391,7 @@ 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();
@@ -381,6 +399,7 @@ 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)
@@ -398,12 +417,24 @@ 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);
@@ -414,10 +445,7 @@ function scorePage(page: QueryableWikiPage, query: string): number {
return score;
}
function normalizeLookupKey(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
}
export { resolveQueryableWikiPageByLookup } from "./query-lookup.js";
function buildLookupCandidates(lookup: string): string[] {
const normalized = normalizeLookupKey(lookup);
@@ -603,22 +631,6 @@ 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,18 +9,29 @@ 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;
state: ImportedSourceState;
buildRendered: (raw: string, updatedAt: string) => string;
}): 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;
scopeKey?: string;
state: ImportedSourceState;
} & (
| {
buildRendered: (raw: string, updatedAt: string) => string;
rendered?: never;
}
| {
rendered: string;
buildRendered?: never;
}
),
): 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();
@@ -38,8 +49,10 @@ export async function writeImportedSourcePage(params: {
return { pagePath: params.pagePath, changed: false, created };
}
const raw = await fs.readFile(params.sourcePath, "utf8");
const rendered = params.buildRendered(raw, updatedAt);
const rendered =
"rendered" in params
? params.rendered
: params.buildRendered(await fs.readFile(params.sourcePath, "utf8"), updatedAt);
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
if (existing !== rendered) {
await fs.writeFile(pageAbsPath, rendered, "utf8");
@@ -50,6 +63,7 @@ export async function writeImportedSourcePage(params: {
state: params.state,
entry: {
group: params.group,
...(params.scopeKey ? { scopeKey: params.scopeKey } : {}),
pagePath: params.pagePath,
sourcePath: params.sourcePath,
sourceUpdatedAtMs: params.sourceUpdatedAtMs,

View File

@@ -1,10 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local";
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local" | "import";
export type MemoryWikiImportedSourceStateEntry = {
group: MemoryWikiImportedSourceGroup;
scopeKey?: string;
pagePath: string;
sourcePath: string;
sourceUpdatedAtMs: number;
@@ -100,10 +101,15 @@ 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)) {
if (
entry.group !== params.group ||
params.activeKeys.has(syncKey) ||
(params.scopeKey !== undefined && entry.scopeKey !== params.scopeKey)
) {
continue;
}
const pageAbsPath = path.join(params.vaultRoot, entry.pagePath);

View File

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

View File

@@ -1,3 +1,4 @@
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";
@@ -7,7 +8,7 @@ import {
applyAuthorizationHeaderForUrl,
GRAPH_ROOT,
inferPlaceholder,
readNestedString,
isRecord,
isUrlAllowed,
type MSTeamsAttachmentFetchPolicy,
normalizeContentType,
@@ -38,6 +39,17 @@ 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, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
@@ -78,17 +78,6 @@ 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,5 +1,6 @@
import crypto from "node:crypto";
import { isRecord, readNestedString } from "./attachments/shared.js";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { isRecord } from "./attachments/shared.js";
import { resolveMSTeamsStorePath } from "./storage.js";
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
@@ -87,6 +88,11 @@ 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 = normalizeOptionalString(value)?.toLowerCase() ?? "";
const normalized = (value ?? "").trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,13 @@ 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,
@@ -96,7 +103,7 @@ export async function fetchSlackScopes(
if (scopes.length > 0) {
return { ok: true, scopes, source: method };
}
const error = isRecord(result) ? normalizeOptionalString(result.error) : undefined;
const error = readError(result);
if (error) {
errors.push(`${method}: ${error}`);
}

View File

@@ -9,7 +9,6 @@ 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,7 +41,6 @@ import {
summarizeText,
type SpeechModelOverridePolicy,
type SpeechProviderConfig,
type SpeechProviderOverrides,
type SpeechVoiceOption,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
@@ -174,7 +173,7 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
if (envPath) {
return resolveUserPath(envPath);
}
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
return path.join(CONFIG_DIR, "settings", "tts.json");
}
function resolveModelOverridePolicy(
@@ -328,9 +327,7 @@ export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig {
mode: raw.mode ?? "final",
provider:
normalizeConfiguredSpeechProviderId(raw.provider) ??
(providerSource === "config"
? (normalizeOptionalString(raw.provider)?.toLowerCase() ?? "")
: ""),
(providerSource === "config" ? raw.provider?.trim().toLowerCase() || "" : ""),
providerSource,
summaryModel: normalizeOptionalString(raw.summaryModel),
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
@@ -503,66 +500,6 @@ 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,7 +1,6 @@
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 {
@@ -80,15 +79,11 @@ function findVoice(voices: SpeechVoiceOption[], query: string): SpeechVoiceOptio
if (byId) {
return byId;
}
const exactName = voices.find(
(v) => (normalizeOptionalString(v.name)?.toLowerCase() ?? "") === lower,
);
const exactName = voices.find((v) => (v.name ?? "").trim().toLowerCase() === lower);
if (exactName) {
return exactName;
}
const partial = voices.find((v) =>
(normalizeOptionalString(v.name)?.toLowerCase() ?? "").includes(lower),
);
const partial = voices.find((v) => (v.name ?? "").trim().toLowerCase().includes(lower));
return partial ?? null;
}

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ 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";
@@ -108,9 +107,7 @@ function matchesTelegramRequestAccount(params: {
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,

View File

@@ -114,6 +114,11 @@ 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)));
}
@@ -123,18 +128,18 @@ export function resolveTelegramStatusReactionEmojis(params: {
overrides?: StatusReactionEmojis;
}): Required<StatusReactionEmojis> {
const { overrides } = params;
const queuedFallback = normalizeOptionalString(params.initialEmoji) ?? DEFAULT_EMOJIS.queued;
const queuedFallback = normalizeEmoji(params.initialEmoji) ?? DEFAULT_EMOJIS.queued;
return {
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,
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,
};
}
@@ -143,7 +148,7 @@ export function buildTelegramStatusReactionVariants(
): Map<string, string[]> {
const variantsByRequested = new Map<string, string[]>();
for (const key of STATUS_REACTION_EMOJI_KEYS) {
const requested = normalizeOptionalString(emojis[key]);
const requested = normalizeEmoji(emojis[key]);
if (!requested) {
continue;
}
@@ -220,7 +225,7 @@ export function resolveTelegramReactionVariant(params: {
variantsByRequestedEmoji: Map<string, string[]>;
allowedEmojiReactions?: Set<TelegramReactionEmoji> | null;
}): TelegramReactionEmoji | undefined {
const requestedEmoji = normalizeOptionalString(params.requestedEmoji);
const requestedEmoji = normalizeEmoji(params.requestedEmoji);
if (!requestedEmoji) {
return undefined;
}

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