mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 17:23:20 +08:00
Compare commits
84 Commits
memory-wik
...
codex/task
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed4dbe63a5 | ||
|
|
e8b7694f05 | ||
|
|
db65818d0b | ||
|
|
00c0e9fb36 | ||
|
|
4ba0ce3ed1 | ||
|
|
d18d56f040 | ||
|
|
1e5b026e61 | ||
|
|
f13542f211 | ||
|
|
f54188f600 | ||
|
|
aa61b508d1 | ||
|
|
d6b634bc30 | ||
|
|
a20d96ae31 | ||
|
|
8be79a09b8 | ||
|
|
0ca8eb40c1 | ||
|
|
525e78e3d9 | ||
|
|
ce18c3e9e7 | ||
|
|
be3b7cf875 | ||
|
|
5489bff7c3 | ||
|
|
1604b4a304 | ||
|
|
9a4e35a24f | ||
|
|
cd54f20fe2 | ||
|
|
a8e46e7048 | ||
|
|
5613f5a834 | ||
|
|
f6124f3e17 | ||
|
|
4fa7931b1b | ||
|
|
29732c1459 | ||
|
|
1fdb013599 | ||
|
|
36938bccb5 | ||
|
|
5de04bc1d5 | ||
|
|
967ecddfed | ||
|
|
6bd6f4d27c | ||
|
|
4dc16e1567 | ||
|
|
af1cf77b16 | ||
|
|
fbdb20ffd3 | ||
|
|
3139d2007e | ||
|
|
55f07e0381 | ||
|
|
0bbd70ac79 | ||
|
|
9437c24764 | ||
|
|
1baff9c64c | ||
|
|
874ca3d691 | ||
|
|
1395650d95 | ||
|
|
0b04d27beb | ||
|
|
881f41d4a1 | ||
|
|
1b20303c0c | ||
|
|
1e5f5fa319 | ||
|
|
d008e2d015 | ||
|
|
b6a806d67b | ||
|
|
56b0714004 | ||
|
|
3fbb229d04 | ||
|
|
ec708f44df | ||
|
|
dbcb1f06ec | ||
|
|
90e8bef253 | ||
|
|
f7957d3bb7 | ||
|
|
b21dd9c635 | ||
|
|
733063e31c | ||
|
|
d84ac5b1eb | ||
|
|
9dda94c0f7 | ||
|
|
24d4acb274 | ||
|
|
b4d0d6fcc9 | ||
|
|
2b5f663c9c | ||
|
|
67e6f88e42 | ||
|
|
a5efc9a6c9 | ||
|
|
74ea9de6f2 | ||
|
|
434d56a948 | ||
|
|
f54a57b80a | ||
|
|
f1bdfca1ed | ||
|
|
cb29ecc100 | ||
|
|
255abc57b9 | ||
|
|
edfc8eb91a | ||
|
|
dd3e86d35b | ||
|
|
bf040219e4 | ||
|
|
4d4dbe8e15 | ||
|
|
c2f9de3935 | ||
|
|
dbc7710938 | ||
|
|
5ae27dfb5a | ||
|
|
e3cb19d162 | ||
|
|
524951e124 | ||
|
|
16877efba3 | ||
|
|
9db1a7acf0 | ||
|
|
4329d94de3 | ||
|
|
34c78d3ba4 | ||
|
|
991e25b880 | ||
|
|
f510576959 | ||
|
|
ea5faa9b39 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -753,6 +753,11 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:bundled
|
||||
|
||||
- name: Run extension package boundary TypeScript check
|
||||
id: extension_package_boundary_tsc
|
||||
continue-on-error: true
|
||||
run: pnpm run test:extensions:package-boundary
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
id: no_raw_window_open
|
||||
continue-on-error: true
|
||||
@@ -797,6 +802,7 @@ jobs:
|
||||
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
|
||||
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
|
||||
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
|
||||
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
|
||||
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
|
||||
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||
@@ -820,6 +826,7 @@ jobs:
|
||||
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
|
||||
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
|
||||
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
|
||||
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
|
||||
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
|
||||
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
|
||||
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
|
||||
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
|
||||
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
|
||||
@@ -23,20 +24,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
|
||||
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
|
||||
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
|
||||
- Memory/wiki: add task-backed `wiki import` with automatic local-file and markdown-vault detection so existing note stores can be backfilled into source pages with shared task progress instead of ad hoc one-off ingest flows. Thanks @vincentkoc.
|
||||
- Memory/wiki: keep imported Obsidian and Logseq notes readable by preserving markdown note bodies plus imported tags, aliases, and link hints instead of flattening every vault note into a fenced text blob. Thanks @vincentkoc.
|
||||
- Memory/wiki: use imported vault tags, aliases, and link hints in the compiled digest and wiki search ranking so imported Obsidian and Logseq notes are easier to recall by their original note metadata. Thanks @vincentkoc.
|
||||
- Memory/wiki: use imported vault aliases and link hints when building `## Related` backlinks so imported Obsidian and Logseq notes can reconnect their note graph after import. Thanks @vincentkoc.
|
||||
- Memory/wiki: let `wiki_get` and metadata updates resolve imported vault titles and aliases directly, so imported notes stay addressable by their original note names instead of only generated paths. Thanks @vincentkoc.
|
||||
- Memory/wiki: upgrade `reports/import-review.md` to flag duplicate imported titles and aliases plus obviously low-signal notes, so large vault imports are easier to triage before promotion or synthesis work. Thanks @vincentkoc.
|
||||
- Memory/wiki: add duplicate-body clustering to `reports/import-review.md` so large vault imports can surface copied or renamed notes even when titles and aliases differ. Thanks @vincentkoc.
|
||||
- Memory/wiki: preserve imported markdown vault relative paths in digest, lookup, and related-link reconstruction so imported note identity survives search and `wiki_get`. Thanks @vincentkoc.
|
||||
- Memory/wiki: auto-detect and import ChatGPT export JSON files as conversation source pages instead of misclassifying them as generic local files. Thanks @vincentkoc.
|
||||
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
|
||||
- CLI/infer: keep provider-backed infer behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
|
||||
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
|
||||
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
|
||||
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
|
||||
@@ -46,12 +39,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
|
||||
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
|
||||
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
|
||||
- Memory/wiki: follow `current_node` when importing ChatGPT export mapping trees so imported conversation transcripts stop pulling in stale alternate branches. Thanks @vincentkoc.
|
||||
- Memory/wiki: extract readable text from object-shaped ChatGPT export message parts so imported conversation transcripts stop dropping rich content blocks. Thanks @vincentkoc.
|
||||
- Memory/wiki: preserve conversation turn order for ChatGPT imports when timestamps are missing or tied, so imported transcripts stop scrambling equal-time messages and current-branch lineage. Thanks @vincentkoc.
|
||||
- Memory/wiki: skip hidden and tool-role ChatGPT export messages during import so conversation source pages stop filling up with export-only scaffolding. Thanks @vincentkoc.
|
||||
- Memory/wiki: skip ChatGPT export conversations that end up with no readable visible turns, so imports stop generating empty placeholder source pages from hidden/tool-only records. Thanks @vincentkoc.
|
||||
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
|
||||
- Nodes/exec approvals: keep `host=node` POSIX transport shell wrappers (`/bin/sh -lc ...`) aligned with inner-command allowlist analysis so allowlisted scripts stop prompting unnecessarily, while Windows `cmd.exe` wrapper runs stay approval-gated. (#62401) Thanks @ngutman.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
|
||||
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
|
||||
@@ -65,6 +54,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
|
||||
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
|
||||
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
|
||||
- Providers/Ollama: stop warning that Ollama could not be reached when discovery only sees empty default local stubs, while still keeping real explicit Ollama overrides loud when the endpoint is unreachable.
|
||||
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
|
||||
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
|
||||
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
|
||||
@@ -262,6 +252,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
|
||||
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
|
||||
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
|
||||
- Memory/dreaming: make Dreams config reads and writes respect the selected memory slot plugin (including `doctor.memory.status` and Control UI fallback state) instead of always targeting `memory-core`. (#62275) Thanks @SnowSky1.
|
||||
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
|
||||
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
|
||||
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.
|
||||
|
||||
@@ -537,6 +537,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let bootstrapcontextmode: AnyCodable?
|
||||
public let bootstrapcontextrunkind: AnyCodable?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let idempotencykey: String
|
||||
@@ -566,6 +568,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
bootstrapcontextmode: AnyCodable?,
|
||||
bootstrapcontextrunkind: AnyCodable?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
idempotencykey: String,
|
||||
@@ -594,6 +598,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.bootstrapcontextmode = bootstrapcontextmode
|
||||
self.bootstrapcontextrunkind = bootstrapcontextrunkind
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -624,6 +630,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case bootstrapcontextmode = "bootstrapContextMode"
|
||||
case bootstrapcontextrunkind = "bootstrapContextRunKind"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
|
||||
@@ -537,6 +537,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let bootstrapcontextmode: AnyCodable?
|
||||
public let bootstrapcontextrunkind: AnyCodable?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let idempotencykey: String
|
||||
@@ -566,6 +568,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
bootstrapcontextmode: AnyCodable?,
|
||||
bootstrapcontextrunkind: AnyCodable?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
idempotencykey: String,
|
||||
@@ -594,6 +598,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.bootstrapcontextmode = bootstrapcontextmode
|
||||
self.bootstrapcontextrunkind = bootstrapcontextrunkind
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -624,6 +630,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case bootstrapcontextmode = "bootstrapContextMode"
|
||||
case bootstrapcontextrunkind = "bootstrapContextRunKind"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
3d483bffbe5abb831df3b1efdf40e1ae0d22d644853a7629ecdaa6d535386ee6 plugin-sdk-api-baseline.json
|
||||
eebeff7cc3ca490d3cae268ea97c5968f37f50fe1a9c7eabeeab85a4ae66a9d9 plugin-sdk-api-baseline.jsonl
|
||||
bcf997afb562b69552d0bf772ccad85b48df14cc3f314fdd5265644702fdfd2d plugin-sdk-api-baseline.json
|
||||
fcef1106262e6d0f53d67d1f1e968b34cd49ed45c89972364fe6c7d9ffcf1f5b plugin-sdk-api-baseline.jsonl
|
||||
|
||||
119
docs/cli/capability.md
Normal file
119
docs/cli/capability.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
summary: "Infer-first CLI for provider-backed model, image, audio, TTS, video, web, and embedding workflows"
|
||||
read_when:
|
||||
- Adding or modifying `openclaw infer` commands
|
||||
- Designing stable headless capability automation
|
||||
title: "Inference CLI"
|
||||
---
|
||||
|
||||
# Inference CLI
|
||||
|
||||
`openclaw infer` is the canonical headless surface for provider-backed inference workflows.
|
||||
|
||||
`openclaw capability` remains supported as a fallback alias for compatibility.
|
||||
|
||||
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
|
||||
|
||||
## Command tree
|
||||
|
||||
```text
|
||||
openclaw infer
|
||||
list
|
||||
inspect
|
||||
|
||||
model
|
||||
run
|
||||
list
|
||||
inspect
|
||||
providers
|
||||
auth login
|
||||
auth logout
|
||||
auth status
|
||||
|
||||
image
|
||||
generate
|
||||
edit
|
||||
describe
|
||||
describe-many
|
||||
providers
|
||||
|
||||
audio
|
||||
transcribe
|
||||
providers
|
||||
|
||||
tts
|
||||
convert
|
||||
voices
|
||||
providers
|
||||
status
|
||||
enable
|
||||
disable
|
||||
set-provider
|
||||
|
||||
video
|
||||
generate
|
||||
describe
|
||||
providers
|
||||
|
||||
web
|
||||
search
|
||||
fetch
|
||||
providers
|
||||
|
||||
embedding
|
||||
create
|
||||
providers
|
||||
```
|
||||
|
||||
## Transport
|
||||
|
||||
Supported transport flags:
|
||||
|
||||
- `--local`
|
||||
- `--gateway`
|
||||
|
||||
Default transport is implicit auto at the command-family level:
|
||||
|
||||
- Stateless execution commands default to local.
|
||||
- Gateway-managed state commands default to gateway.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw infer model run --prompt "hello" --json
|
||||
openclaw infer image generate --prompt "friendly lobster" --json
|
||||
openclaw infer tts status --json
|
||||
openclaw infer embedding create --text "hello world" --json
|
||||
```
|
||||
|
||||
## JSON output
|
||||
|
||||
Capability commands normalize JSON output under a shared envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"capability": "image.generate",
|
||||
"transport": "local",
|
||||
"provider": "openai",
|
||||
"model": "gpt-image-1",
|
||||
"attempts": [],
|
||||
"outputs": []
|
||||
}
|
||||
```
|
||||
|
||||
Top-level fields are stable:
|
||||
|
||||
- `ok`
|
||||
- `capability`
|
||||
- `transport`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `attempts`
|
||||
- `outputs`
|
||||
- `error`
|
||||
|
||||
## Notes
|
||||
|
||||
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
|
||||
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
|
||||
@@ -35,6 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`logs`](/cli/logs)
|
||||
- [`system`](/cli/system)
|
||||
- [`models`](/cli/models)
|
||||
- [`infer`](/cli/capability)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`directory`](/cli/directory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
@@ -248,6 +249,16 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
fallbacks list|add|remove|clear
|
||||
image-fallbacks list|add|remove|clear
|
||||
scan
|
||||
infer (alias: capability)
|
||||
list
|
||||
inspect
|
||||
model run|list|inspect|providers|auth login|logout|status
|
||||
image generate|edit|describe|describe-many|providers
|
||||
audio transcribe|providers
|
||||
tts convert|voices|providers|status|enable|disable|set-provider
|
||||
video generate|describe|providers
|
||||
web search|fetch|providers
|
||||
embedding create|providers
|
||||
auth add|login|login-github-copilot|setup-token|paste-token
|
||||
auth order get|set|clear
|
||||
sandbox
|
||||
|
||||
@@ -360,7 +360,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- or `npm install -g @google/gemini-cli`
|
||||
- Enable: `openclaw plugins enable google`
|
||||
- Login: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
- Default model: `google-gemini-cli/gemini-3-flash-preview`
|
||||
- Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores
|
||||
tokens in auth profiles on the gateway host.
|
||||
- If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host.
|
||||
|
||||
@@ -214,8 +214,10 @@ The bundled OpenAI plugin also registers a default for `codex-cli`:
|
||||
The bundled Google plugin also registers a default for `google-gemini-cli`:
|
||||
|
||||
- `command: "gemini"`
|
||||
- `args: ["--prompt", "--output-format", "json"]`
|
||||
- `resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"]`
|
||||
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
|
||||
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
|
||||
- `imageArg: "@"`
|
||||
- `imagePathScope: "workspace"`
|
||||
- `modelArg: "--model"`
|
||||
- `sessionMode: "existing"`
|
||||
- `sessionIdFields: ["session_id", "sessionId"]`
|
||||
@@ -251,8 +253,9 @@ opt into a generated MCP config overlay with `bundleMcp: true`.
|
||||
|
||||
Current bundled behavior:
|
||||
|
||||
- `codex-cli`: no bundle MCP overlay
|
||||
- `google-gemini-cli`: no bundle MCP overlay
|
||||
- `claude-cli`: generated strict MCP config file
|
||||
- `codex-cli`: inline config overrides for `mcp_servers`
|
||||
- `google-gemini-cli`: generated Gemini system settings file
|
||||
|
||||
When bundle MCP is enabled, OpenClaw:
|
||||
|
||||
@@ -260,8 +263,8 @@ When bundle MCP is enabled, OpenClaw:
|
||||
- authenticates the bridge with a per-session token (`OPENCLAW_MCP_TOKEN`)
|
||||
- scopes tool access to the current session, account, and channel context
|
||||
- loads enabled bundle-MCP servers for the current workspace
|
||||
- merges them with any existing backend `--mcp-config`
|
||||
- rewrites the CLI args to pass `--strict-mcp-config --mcp-config <generated-file>`
|
||||
- merges them with any existing backend MCP config/settings shape
|
||||
- rewrites the launch config using the backend-owned integration mode from the owning extension
|
||||
|
||||
If no MCP servers are enabled, OpenClaw still injects a strict config when a
|
||||
backend opts into bundle MCP so background runs stay isolated.
|
||||
|
||||
@@ -701,7 +701,7 @@ for usage/billing and raise limits as needed.
|
||||
- npm: `npm install -g @google/gemini-cli`
|
||||
2. Enable the plugin: `openclaw plugins enable google`
|
||||
3. Login: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
4. Default model after login: `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
4. Default model after login: `google-gemini-cli/gemini-3-flash-preview`
|
||||
5. If requests fail, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host
|
||||
|
||||
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).
|
||||
|
||||
@@ -300,6 +300,7 @@ Notes:
|
||||
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
|
||||
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
|
||||
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
|
||||
|
||||
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ An alternative provider `google-gemini-cli` uses PKCE OAuth instead of an API
|
||||
key. This is an unofficial integration; some users report account
|
||||
restrictions. Use at your own risk.
|
||||
|
||||
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
- Default model: `google-gemini-cli/gemini-3-flash-preview`
|
||||
- Alias: `gemini-cli`
|
||||
- Install prerequisite: local Gemini CLI available as `gemini`
|
||||
- Homebrew: `brew install gemini-cli`
|
||||
|
||||
@@ -54,7 +54,7 @@ model as `provider/model`.
|
||||
|
||||
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
|
||||
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
|
||||
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3.1-pro-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.5.1"
|
||||
"acpx": "0.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
|
||||
6
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
6
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
@@ -45,7 +45,11 @@ declare module "acpx/runtime" {
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
reason?: string;
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
|
||||
|
||||
@@ -1,74 +1,37 @@
|
||||
import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
|
||||
import type { AcpSessionStore } from "acpx/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
capturedStore: undefined as AcpSessionStore | undefined,
|
||||
};
|
||||
|
||||
class MockAcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions) {
|
||||
state.capturedStore = options.sessionStore;
|
||||
}
|
||||
|
||||
isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async probeAvailability() {}
|
||||
|
||||
async doctor() {
|
||||
return { ok: true, message: "ok" };
|
||||
}
|
||||
|
||||
async ensureSession() {
|
||||
return {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
} satisfies AcpRuntimeHandle;
|
||||
}
|
||||
|
||||
async *runTurn() {}
|
||||
|
||||
getCapabilities() {
|
||||
return { controls: [] };
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setMode() {}
|
||||
|
||||
async setConfigOption() {}
|
||||
|
||||
async cancel() {}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
function makeRuntime(baseStore: AcpSessionStore): {
|
||||
runtime: AcpxRuntime;
|
||||
wrappedStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
delegate: { close: AcpRuntime["close"] };
|
||||
} {
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
MockAcpxRuntime,
|
||||
runtime,
|
||||
wrappedStore: (
|
||||
runtime as unknown as {
|
||||
sessionStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
}
|
||||
).sessionStore,
|
||||
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("acpx/runtime", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: mocks.MockAcpxRuntime,
|
||||
createAcpRuntime: vi.fn(),
|
||||
createAgentRegistry: vi.fn(),
|
||||
createFileSessionStore: vi.fn(),
|
||||
decodeAcpxRuntimeHandleState: vi.fn(),
|
||||
encodeAcpxRuntimeHandleState: vi.fn(),
|
||||
}));
|
||||
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
}
|
||||
|
||||
describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.capturedStore = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
|
||||
@@ -77,20 +40,9 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
const { runtime, wrappedStore } = makeRuntime(baseStore);
|
||||
|
||||
const wrappedStore = mocks.state.capturedStore;
|
||||
expect(wrappedStore).toBeDefined();
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
@@ -99,17 +51,17 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
});
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrappedStore?.save({
|
||||
await wrappedStore.save({
|
||||
acpxRecordId: "fresh-record",
|
||||
name: "agent:codex:acp:binding:test",
|
||||
} as never);
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
||||
@@ -121,18 +73,8 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
const wrappedStore = mocks.state.capturedStore;
|
||||
expect(wrappedStore).toBeDefined();
|
||||
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
|
||||
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
@@ -144,7 +86,16 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
discardPersistentState: true,
|
||||
});
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(close).toHaveBeenCalledWith({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
})
|
||||
.then(() => {
|
||||
if (input.discardPersistentState) {
|
||||
|
||||
@@ -19,12 +19,14 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
liveTest: {
|
||||
defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
defaultImageProbe: true,
|
||||
defaultMcpProbe: true,
|
||||
docker: {
|
||||
npmPackage: "@anthropic-ai/claude-code",
|
||||
binaryName: "claude",
|
||||
},
|
||||
},
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "claude-config-file",
|
||||
config: {
|
||||
command: "claude",
|
||||
args: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`;
|
||||
@@ -92,7 +93,7 @@ const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
|
||||
const CLAUDE_SAFE_SETTING_SOURCES = "user";
|
||||
|
||||
export function isClaudeCliProvider(providerId: string): boolean {
|
||||
return providerId.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID;
|
||||
return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID;
|
||||
}
|
||||
|
||||
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"contracts": {
|
||||
"webSearchProviders": ["brave"]
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
@@ -26,11 +27,11 @@ export function resolveBrowserControlAuth(
|
||||
}
|
||||
|
||||
function shouldAutoGenerateBrowserAuth(env: NodeJS.ProcessEnv): boolean {
|
||||
const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
|
||||
const nodeEnv = normalizeLowercaseStringOrEmpty(env.NODE_ENV);
|
||||
if (nodeEnv === "test") {
|
||||
return false;
|
||||
}
|
||||
const vitest = (env.VITEST ?? "").trim().toLowerCase();
|
||||
const vitest = normalizeLowercaseStringOrEmpty(env.VITEST);
|
||||
if (vitest && vitest !== "0" && vitest !== "false" && vitest !== "off") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
|
||||
function firstHeader(value: string | string[] | undefined): string {
|
||||
@@ -35,7 +36,7 @@ export function shouldRejectBrowserMutation(params: {
|
||||
|
||||
// Strong signal when present: browser says this is cross-site.
|
||||
// Avoid being overly clever with "same-site" since localhost vs 127.0.0.1 may differ.
|
||||
const secFetchSite = (params.secFetchSite ?? "").trim().toLowerCase();
|
||||
const secFetchSite = normalizeLowercaseStringOrEmpty(params.secFetchSite);
|
||||
if (secFetchSite === "cross-site") {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { browserCloseTab } from "./client.js";
|
||||
|
||||
export type TrackedSessionBrowserTab = {
|
||||
@@ -11,7 +12,7 @@ export type TrackedSessionBrowserTab = {
|
||||
const trackedTabsBySession = new Map<string, Map<string, TrackedSessionBrowserTab>>();
|
||||
|
||||
function normalizeSessionKey(raw: string): string {
|
||||
return raw.trim().toLowerCase();
|
||||
return normalizeOptionalLowercaseString(raw) ?? "";
|
||||
}
|
||||
|
||||
function normalizeTargetId(raw: string): string {
|
||||
@@ -19,11 +20,7 @@ function normalizeTargetId(raw: string): string {
|
||||
}
|
||||
|
||||
function normalizeProfile(raw?: string): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
return normalizeOptionalLowercaseString(raw);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw?: string): string | undefined {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ChutesModelsModule = typeof import("./models.js");
|
||||
|
||||
let chutesModels: ChutesModelsModule;
|
||||
import {
|
||||
buildChutesModelDefinition,
|
||||
CHUTES_MODEL_CATALOG,
|
||||
clearChutesModelCacheForTests,
|
||||
discoverChutesModels,
|
||||
} from "./models.js";
|
||||
|
||||
async function withLiveChutesDiscovery<T>(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
@@ -44,14 +46,13 @@ function createAuthEchoFetchMock() {
|
||||
}
|
||||
|
||||
describe("chutes-models", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
chutesModels = await import("./models.js");
|
||||
beforeEach(() => {
|
||||
clearChutesModelCacheForTests();
|
||||
});
|
||||
|
||||
it("buildChutesModelDefinition returns config with required fields", () => {
|
||||
const entry = chutesModels.CHUTES_MODEL_CATALOG[0];
|
||||
const def = chutesModels.buildChutesModelDefinition(entry);
|
||||
const entry = CHUTES_MODEL_CATALOG[0];
|
||||
const def = buildChutesModelDefinition(entry);
|
||||
expect(def.id).toBe(entry.id);
|
||||
expect(def.name).toBe(entry.name);
|
||||
expect(def.reasoning).toBe(entry.reasoning);
|
||||
@@ -63,14 +64,14 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("discoverChutesModels returns static catalog when accessToken is empty", async () => {
|
||||
const models = await chutesModels.discoverChutesModels("");
|
||||
expect(models).toHaveLength(chutesModels.CHUTES_MODEL_CATALOG.length);
|
||||
expect(models.map((m) => m.id)).toEqual(chutesModels.CHUTES_MODEL_CATALOG.map((m) => m.id));
|
||||
const models = await discoverChutesModels("");
|
||||
expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length);
|
||||
expect(models.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
|
||||
});
|
||||
|
||||
it("discoverChutesModels returns static catalog in test env by default", async () => {
|
||||
const models = await chutesModels.discoverChutesModels("test-token");
|
||||
expect(models).toHaveLength(chutesModels.CHUTES_MODEL_CATALOG.length);
|
||||
const models = await discoverChutesModels("test-token");
|
||||
expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length);
|
||||
expect(models[0]?.id).toBe("Qwen/Qwen3-32B");
|
||||
});
|
||||
|
||||
@@ -93,7 +94,7 @@ describe("chutes-models", () => {
|
||||
}),
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await chutesModels.discoverChutesModels("test-token-real-fetch");
|
||||
const models = await discoverChutesModels("test-token-real-fetch");
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
if (models.length === 3) {
|
||||
expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE");
|
||||
@@ -146,7 +147,7 @@ describe("chutes-models", () => {
|
||||
});
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await chutesModels.discoverChutesModels("test-token-error");
|
||||
const models = await discoverChutesModels("test-token-error");
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
@@ -159,10 +160,10 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const first = await chutesModels.discoverChutesModels("chutes-fallback-token");
|
||||
const second = await chutesModels.discoverChutesModels("chutes-fallback-token");
|
||||
expect(first.map((m) => m.id)).toEqual(chutesModels.CHUTES_MODEL_CATALOG.map((m) => m.id));
|
||||
expect(second.map((m) => m.id)).toEqual(chutesModels.CHUTES_MODEL_CATALOG.map((m) => m.id));
|
||||
const first = await discoverChutesModels("chutes-fallback-token");
|
||||
const second = await discoverChutesModels("chutes-fallback-token");
|
||||
expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
|
||||
expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -196,9 +197,9 @@ describe("chutes-models", () => {
|
||||
});
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const modelsA = await chutesModels.discoverChutesModels("chutes-token-a");
|
||||
const modelsB = await chutesModels.discoverChutesModels("chutes-token-b");
|
||||
const modelsASecond = await chutesModels.discoverChutesModels("chutes-token-a");
|
||||
const modelsA = await discoverChutesModels("chutes-token-a");
|
||||
const modelsB = await discoverChutesModels("chutes-token-b");
|
||||
const modelsASecond = await discoverChutesModels("chutes-token-a");
|
||||
expect(modelsA[0]?.id).toBe("private/model-a");
|
||||
expect(modelsB[0]?.id).toBe("private/model-b");
|
||||
expect(modelsASecond[0]?.id).toBe("private/model-a");
|
||||
@@ -211,10 +212,10 @@ describe("chutes-models", () => {
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
await chutesModels.discoverChutesModels(`cache-token-${i}`);
|
||||
await discoverChutesModels(`cache-token-${i}`);
|
||||
}
|
||||
|
||||
await chutesModels.discoverChutesModels("cache-token-0");
|
||||
await discoverChutesModels("cache-token-0");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(151);
|
||||
});
|
||||
});
|
||||
@@ -225,10 +226,10 @@ describe("chutes-models", () => {
|
||||
await withLiveChutesDiscovery(
|
||||
mockFetch,
|
||||
async () => {
|
||||
await chutesModels.discoverChutesModels("token-a");
|
||||
await discoverChutesModels("token-a");
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
await chutesModels.discoverChutesModels("token-b");
|
||||
await chutesModels.discoverChutesModels("token-a");
|
||||
await discoverChutesModels("token-b");
|
||||
await discoverChutesModels("token-a");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ now: "2026-03-01T00:00:00.000Z" },
|
||||
@@ -253,8 +254,8 @@ describe("chutes-models", () => {
|
||||
});
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
await chutesModels.discoverChutesModels("failed-token");
|
||||
await chutesModels.discoverChutesModels("failed-token");
|
||||
await discoverChutesModels("failed-token");
|
||||
await discoverChutesModels("failed-token");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,6 +475,10 @@ interface CacheEntry {
|
||||
|
||||
const modelCache = new Map<string, CacheEntry>();
|
||||
|
||||
export function clearChutesModelCacheForTests(): void {
|
||||
modelCache.clear();
|
||||
}
|
||||
|
||||
function pruneExpiredCacheEntries(now: number = Date.now()): void {
|
||||
for (const [key, entry] of modelCache.entries()) {
|
||||
if (now - entry.time >= CACHE_TTL) {
|
||||
|
||||
@@ -428,10 +428,10 @@ function buildArtifactContext(
|
||||
}
|
||||
|
||||
const artifactContext = {
|
||||
agentId: normalizeContextString(context.agentId),
|
||||
sessionId: normalizeContextString(context.sessionId),
|
||||
messageChannel: normalizeContextString(context.messageChannel),
|
||||
agentAccountId: normalizeContextString(context.agentAccountId),
|
||||
agentId: normalizeOptionalString(context.agentId),
|
||||
sessionId: normalizeOptionalString(context.sessionId),
|
||||
messageChannel: normalizeOptionalString(context.messageChannel),
|
||||
agentAccountId: normalizeOptionalString(context.agentAccountId),
|
||||
};
|
||||
|
||||
return Object.values(artifactContext).some((value) => value !== undefined)
|
||||
@@ -439,11 +439,6 @@ function buildArtifactContext(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeContextString(value: string | undefined): string | undefined {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
|
||||
const patch = params.patch?.trim();
|
||||
const before = params.before;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
|
||||
import {
|
||||
createChannelApproverDmTargetResolver,
|
||||
@@ -103,7 +104,7 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC
|
||||
}),
|
||||
resolveTurnSourceTarget: (request) => {
|
||||
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
|
||||
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
|
||||
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
|
||||
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ChannelDirectoryEntry,
|
||||
DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { fetchDiscord } from "./api.js";
|
||||
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
|
||||
@@ -15,7 +16,7 @@ type DiscordChannel = { id: string; name?: string | null };
|
||||
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
return normalizeOptionalLowercaseString(value) ?? "";
|
||||
}
|
||||
|
||||
function buildUserRank(user: DiscordUser): number {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import {
|
||||
parseDiscordComponentCustomIdForCarbon,
|
||||
parseDiscordModalCustomIdForCarbon,
|
||||
@@ -512,6 +513,11 @@ async function dispatchDiscordComponentEvent(params: {
|
||||
fallbackLimit: 2000,
|
||||
});
|
||||
const token = ctx.token ?? "";
|
||||
const feedbackRest = createDiscordRestClient({
|
||||
cfg: ctx.cfg,
|
||||
token,
|
||||
accountId,
|
||||
}).rest;
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(ctx.cfg, agentId);
|
||||
const replyToMode =
|
||||
ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off";
|
||||
@@ -554,7 +560,7 @@ async function dispatchDiscordComponentEvent(params: {
|
||||
onReplyStart: async () => {
|
||||
try {
|
||||
const { sendTyping } = await loadTypingRuntime();
|
||||
await sendTyping({ client: interaction.client, channelId: typingChannelId });
|
||||
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
|
||||
} catch (err) {
|
||||
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,9 @@ vi.spyOn(configRuntimeModule, "resolveStorePath").mockImplementation(
|
||||
) => configSessionsMocks.resolveStorePath(path, opts) as never) as never,
|
||||
);
|
||||
|
||||
const clientModule = await import("../client.js");
|
||||
const createDiscordRestClientSpy = vi.spyOn(clientModule, "createDiscordRestClient");
|
||||
|
||||
const BASE_CHANNEL_ROUTE = {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
@@ -214,6 +217,7 @@ beforeEach(() => {
|
||||
recordInboundSession.mockClear();
|
||||
readSessionUpdatedAt.mockClear();
|
||||
resolveStorePath.mockClear();
|
||||
createDiscordRestClientSpy.mockClear();
|
||||
dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult());
|
||||
recordInboundSession.mockResolvedValue(undefined);
|
||||
readSessionUpdatedAt.mockReturnValue(undefined);
|
||||
@@ -278,7 +282,7 @@ function expectAckReactionRuntimeOptions(params?: {
|
||||
messages.removeAckAfterReply = params.removeAckAfterReply;
|
||||
}
|
||||
return expect.objectContaining({
|
||||
rest: {},
|
||||
rest: expect.anything(),
|
||||
...(Object.keys(messages).length > 0
|
||||
? { cfg: expect.objectContaining({ messages: expect.objectContaining(messages) }) }
|
||||
: {}),
|
||||
@@ -337,7 +341,7 @@ function expectSinglePreviewEdit() {
|
||||
"c1",
|
||||
"preview-1",
|
||||
{ content: "Hello\nWorld" },
|
||||
{ rest: {} },
|
||||
expect.objectContaining({ rest: expect.anything() }),
|
||||
);
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
}
|
||||
@@ -397,6 +401,39 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses separate REST clients for feedback and reply delivery", async () => {
|
||||
const feedbackRest = { post: vi.fn(async () => undefined) };
|
||||
const deliveryRest = { post: vi.fn(async () => undefined) };
|
||||
createDiscordRestClientSpy
|
||||
.mockReturnValueOnce({
|
||||
token: "feedback-token",
|
||||
rest: feedbackRest as never,
|
||||
account: { config: {} } as never,
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
token: "delivery-token",
|
||||
rest: deliveryRest as never,
|
||||
account: { config: {} } as never,
|
||||
});
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "hello" });
|
||||
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
|
||||
});
|
||||
|
||||
const ctx = await createBaseContext();
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(sendMocks.reactMessageDiscord).toHaveBeenCalled();
|
||||
expect(sendMocks.reactMessageDiscord.mock.calls[0]?.[3]).toEqual(
|
||||
expect.objectContaining({ rest: feedbackRest }),
|
||||
);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ rest: deliveryRest }),
|
||||
);
|
||||
expect(feedbackRest).not.toBe(deliveryRest);
|
||||
});
|
||||
|
||||
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onReasoningStream?.();
|
||||
@@ -733,7 +770,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
"c1",
|
||||
"preview-1",
|
||||
{ content: longReply },
|
||||
{ rest: {} },
|
||||
expect.objectContaining({ rest: expect.anything() }),
|
||||
);
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
|
||||
import { createDiscordDraftStream } from "../draft-stream.js";
|
||||
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
|
||||
@@ -209,9 +210,19 @@ export async function processDiscordMessage(
|
||||
const shouldSendAckReaction = shouldAckReaction();
|
||||
const statusReactionsEnabled =
|
||||
shouldSendAckReaction && cfg.messages?.statusReactions?.enabled !== false;
|
||||
const feedbackRest = createDiscordRestClient({
|
||||
cfg,
|
||||
token,
|
||||
accountId,
|
||||
}).rest as unknown as RequestClient;
|
||||
const deliveryRest = createDiscordRestClient({
|
||||
cfg,
|
||||
token,
|
||||
accountId,
|
||||
}).rest as unknown as RequestClient;
|
||||
// Discord outbound helpers expect Carbon's request client shape explicitly.
|
||||
const ackReactionContext = createDiscordAckReactionContext({
|
||||
rest: client.rest as unknown as RequestClient,
|
||||
rest: feedbackRest,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
@@ -522,7 +533,7 @@ export async function processDiscordMessage(
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
typing: {
|
||||
start: () => sendTyping({ client, channelId: typingChannelId }),
|
||||
start: () => sendTyping({ rest: feedbackRest, channelId: typingChannelId }),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerbose,
|
||||
@@ -560,7 +571,7 @@ export async function processDiscordMessage(
|
||||
: messageChannelId;
|
||||
const draftStream = canStreamDraft
|
||||
? createDiscordDraftStream({
|
||||
rest: client.rest,
|
||||
rest: deliveryRest,
|
||||
channelId: deliverChannelId,
|
||||
maxChars: draftMaxChars,
|
||||
replyToMessageId: draftReplyToMessageId,
|
||||
@@ -746,7 +757,7 @@ export async function processDiscordMessage(
|
||||
deliverChannelId,
|
||||
previewMessageId,
|
||||
{ content: previewFinalText },
|
||||
{ rest: client.rest },
|
||||
{ rest: deliveryRest },
|
||||
);
|
||||
finalizedViaPreviewMessage = true;
|
||||
replyReference.markSent();
|
||||
@@ -779,7 +790,7 @@ export async function processDiscordMessage(
|
||||
deliverChannelId,
|
||||
messageIdAfterStop,
|
||||
{ content: previewFinalText },
|
||||
{ rest: client.rest },
|
||||
{ rest: deliveryRest },
|
||||
);
|
||||
finalizedViaPreviewMessage = true;
|
||||
replyReference.markSent();
|
||||
@@ -812,7 +823,7 @@ export async function processDiscordMessage(
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
rest: client.rest,
|
||||
rest: deliveryRest,
|
||||
runtime,
|
||||
replyToId,
|
||||
replyToMode,
|
||||
|
||||
@@ -95,18 +95,19 @@ async function runGuildSlashCommand(params?: {
|
||||
}
|
||||
|
||||
function expectNotUnauthorizedReply(interaction: MockCommandInteraction) {
|
||||
expect(interaction.reply).not.toHaveBeenCalledWith(
|
||||
expect(interaction.followUp).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: "You are not authorized to use this command." }),
|
||||
);
|
||||
}
|
||||
|
||||
function expectUnauthorizedReply(interaction: MockCommandInteraction) {
|
||||
expect(interaction.reply).toHaveBeenCalledWith(
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "You are not authorized to use this command.",
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
expect(interaction.reply).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("Discord native slash commands with commands.allowFrom", () => {
|
||||
@@ -279,8 +280,10 @@ describe("Discord native slash commands with commands.allowFrom", () => {
|
||||
| undefined;
|
||||
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
|
||||
|
||||
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply }));
|
||||
expect(interaction.followUp).not.toHaveBeenCalled();
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: longReply }),
|
||||
);
|
||||
expect(interaction.reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows expired slash interactions before dispatch when defer returns Unknown interaction", async () => {
|
||||
|
||||
@@ -282,9 +282,10 @@ async function expectPairCommandReply(params: {
|
||||
);
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(params.interaction.reply).toHaveBeenCalledWith(
|
||||
expect(params.interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: "paired:now" }),
|
||||
);
|
||||
expect(params.interaction.reply).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function createStatusCommand(cfg: OpenClawConfig) {
|
||||
@@ -465,12 +466,13 @@ describe("Discord native plugin command dispatch", () => {
|
||||
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).toHaveBeenCalledWith(
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "You are not authorized to use this command.",
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
expect(interaction.reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects group DM slash commands outside dm.groupChannels before dispatch", async () => {
|
||||
@@ -501,11 +503,12 @@ describe("Discord native plugin command dispatch", () => {
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).toHaveBeenCalledWith(
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "This group DM is not allowed.",
|
||||
}),
|
||||
);
|
||||
expect(interaction.reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
|
||||
@@ -540,9 +543,10 @@ describe("Discord native plugin command dispatch", () => {
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).toHaveBeenCalledWith(
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: "direct plugin output" }),
|
||||
);
|
||||
expect(interaction.reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards Discord thread metadata into direct plugin command execution", async () => {
|
||||
|
||||
@@ -715,7 +715,9 @@ export function createDiscordNativeCommand(params: {
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
preferFollowUp: false,
|
||||
// Slash commands are deferred up front, so all later responses must use
|
||||
// follow-up/edit semantics instead of the initial reply endpoint.
|
||||
preferFollowUp: true,
|
||||
threadBindings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
|
||||
DEFAULT_THREAD_BINDING_MAX_AGE_MS,
|
||||
@@ -112,14 +113,7 @@ export function normalizeTargetKind(
|
||||
}
|
||||
|
||||
export function normalizeThreadId(raw: unknown): string | undefined {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return String(Math.floor(raw));
|
||||
}
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
return normalizeOptionalStringifiedId(raw);
|
||||
}
|
||||
|
||||
export function toBindingRecordKey(params: { accountId: string; threadId: string }): string {
|
||||
|
||||
42
extensions/discord/src/monitor/typing.test.ts
Normal file
42
extensions/discord/src/monitor/typing.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
describe("sendTyping", () => {
|
||||
it("uses the direct Discord typing REST endpoint", async () => {
|
||||
const rest = {
|
||||
post: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await sendTyping({
|
||||
// @ts-expect-error test stub only needs rest.post
|
||||
rest,
|
||||
channelId: "12345",
|
||||
});
|
||||
|
||||
expect(rest.post).toHaveBeenCalledTimes(1);
|
||||
expect(rest.post).toHaveBeenCalledWith(Routes.channelTyping("12345"));
|
||||
});
|
||||
|
||||
it("times out when the typing endpoint hangs", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const rest = {
|
||||
post: vi.fn(() => new Promise(() => {})),
|
||||
};
|
||||
|
||||
const promise = sendTyping({
|
||||
// @ts-expect-error test stub only needs rest.post
|
||||
rest,
|
||||
channelId: "12345",
|
||||
});
|
||||
const rejection = expect(promise).rejects.toThrow("discord typing start timed out");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await rejection;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
|
||||
export async function sendTyping(params: { client: Client; channelId: string }) {
|
||||
const channel = await params.client.fetchChannel(params.channelId);
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
|
||||
await channel.triggerTyping();
|
||||
const DISCORD_TYPING_START_TIMEOUT_MS = 5_000;
|
||||
|
||||
export async function sendTyping(params: { rest: RequestClient; channelId: string }) {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`discord typing start timed out after ${DISCORD_TYPING_START_TIMEOUT_MS}ms`),
|
||||
);
|
||||
}, DISCORD_TYPING_START_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
await Promise.race([params.rest.post(Routes.channelTyping(params.channelId)), timeoutPromise]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type DiscordSessionKeyContext = {
|
||||
ChatType?: string;
|
||||
From?: string;
|
||||
@@ -5,7 +7,7 @@ type DiscordSessionKeyContext = {
|
||||
};
|
||||
|
||||
function normalizeDiscordChatType(raw?: string): "direct" | "group" | "channel" | undefined {
|
||||
const normalized = (raw ?? "").trim().toLowerCase();
|
||||
const normalized = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -22,7 +24,7 @@ export function normalizeExplicitDiscordSessionKey(
|
||||
sessionKey: string,
|
||||
ctx: DiscordSessionKeyContext,
|
||||
): string {
|
||||
let normalized = sessionKey.trim().toLowerCase();
|
||||
let normalized = normalizeLowercaseStringOrEmpty(sessionKey);
|
||||
if (normalizeDiscordChatType(ctx.ChatType) !== "direct") {
|
||||
return normalized;
|
||||
}
|
||||
@@ -34,8 +36,8 @@ export function normalizeExplicitDiscordSessionKey(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const from = (ctx.From ?? "").trim().toLowerCase();
|
||||
const senderId = (ctx.SenderId ?? "").trim().toLowerCase();
|
||||
const from = normalizeLowercaseStringOrEmpty(ctx.From);
|
||||
const senderId = normalizeLowercaseStringOrEmpty(ctx.SenderId);
|
||||
const fromDiscordId =
|
||||
from.startsWith("discord:") && !from.includes(":channel:") && !from.includes(":group:")
|
||||
? from.slice("discord:".length)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
|
||||
import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js";
|
||||
@@ -9,7 +10,7 @@ export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000;
|
||||
const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]);
|
||||
|
||||
export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean {
|
||||
return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase());
|
||||
return QUICK_ACTION_MENU_KEYS.has(normalizeOptionalLowercaseString(eventKey) ?? "");
|
||||
}
|
||||
|
||||
export function createQuickActionLauncherCard(params: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
@@ -37,9 +38,9 @@ export async function listFeishuDirectoryPeersLive(params: {
|
||||
throw new Error(response.msg || `code ${response.code}`);
|
||||
}
|
||||
|
||||
const q = normalizeLowercaseStringOrEmpty(params.query);
|
||||
for (const user of response.data?.items ?? []) {
|
||||
if (user.open_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = user.name || "";
|
||||
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
peers.push({
|
||||
@@ -90,9 +91,9 @@ export async function listFeishuDirectoryGroupsLive(params: {
|
||||
throw new Error(response.msg || `code ${response.code}`);
|
||||
}
|
||||
|
||||
const q = normalizeLowercaseStringOrEmpty(params.query);
|
||||
for (const chat of response.data?.items ?? []) {
|
||||
if (chat.chat_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = chat.name || "";
|
||||
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
groups.push({
|
||||
|
||||
@@ -9,7 +9,7 @@ const GEMINI_MODEL_ALIASES: Record<string, string> = {
|
||||
flash: "gemini-3.1-flash-preview",
|
||||
"flash-lite": "gemini-3.1-flash-lite-preview",
|
||||
};
|
||||
const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3.1-pro-preview";
|
||||
const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3-flash-preview";
|
||||
|
||||
export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
|
||||
return {
|
||||
@@ -17,17 +17,22 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
|
||||
liveTest: {
|
||||
defaultModelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
defaultImageProbe: true,
|
||||
defaultMcpProbe: true,
|
||||
docker: {
|
||||
npmPackage: "@google/gemini-cli",
|
||||
binaryName: "gemini",
|
||||
},
|
||||
},
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "gemini-system-settings",
|
||||
config: {
|
||||
command: "gemini",
|
||||
args: ["--output-format", "json", "--prompt", "{prompt}"],
|
||||
resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"],
|
||||
output: "json",
|
||||
input: "arg",
|
||||
imageArg: "@",
|
||||
imagePathScope: "workspace",
|
||||
modelArg: "--model",
|
||||
modelAliases: GEMINI_MODEL_ALIASES,
|
||||
sessionMode: "existing",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
|
||||
const GEMINI_2_5_PRO_PREFIX = "gemini-2.5-pro";
|
||||
@@ -54,7 +55,7 @@ function cloneGoogleTemplateModel(params: {
|
||||
}
|
||||
|
||||
function isGoogleGeminiCliProvider(providerId: string): boolean {
|
||||
return providerId.trim().toLowerCase() === GOOGLE_GEMINI_CLI_PROVIDER_ID;
|
||||
return normalizeOptionalLowercaseString(providerId) === GOOGLE_GEMINI_CLI_PROVIDER_ID;
|
||||
}
|
||||
|
||||
function templateIdsForProvider(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
createChannelReplyPipeline,
|
||||
@@ -73,7 +73,7 @@ export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => vo
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
const normalized = normalizeOptionalString(value)?.toLowerCase();
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
|
||||
return "app-url";
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("./onboard.js", () => ({
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
function _registerProvider() {
|
||||
function registerProvider() {
|
||||
return registerProviderWithPluginConfig({});
|
||||
}
|
||||
|
||||
@@ -45,10 +45,20 @@ function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>)
|
||||
|
||||
describe("huggingface plugin", () => {
|
||||
it("skips catalog discovery when plugin discovery is disabled", async () => {
|
||||
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
|
||||
const provider = registerProvider();
|
||||
|
||||
const result = await provider.catalog.run({
|
||||
config: {},
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
huggingface: {
|
||||
config: {
|
||||
discovery: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "hf_test_token",
|
||||
discoveryApiKey: "hf_test_token",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { ircPlugin } from "./channel.js";
|
||||
import { setIrcRuntime } from "./runtime.js";
|
||||
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
|
||||
import {
|
||||
ircSetupAdapter,
|
||||
parsePort,
|
||||
@@ -82,6 +82,7 @@ function installIrcRuntime() {
|
||||
describe("irc setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("parses valid ports and falls back for invalid values", () => {
|
||||
@@ -404,13 +405,11 @@ describe("irc setup", () => {
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = vi.fn();
|
||||
vi.resetModules();
|
||||
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
|
||||
installIrcRuntime();
|
||||
const { ircPlugin: runtimeMockedPlugin } = await import("./channel.js");
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: runtimeMockedPlugin.gateway!.startAccount!,
|
||||
startAccount: ircPlugin.gateway!.startAccount!,
|
||||
account: buildAccount(),
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import { resolveLineGroupConfigEntry } from "./group-keys.js";
|
||||
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
type EventSource = webhook.Source | undefined;
|
||||
type MessageEvent = webhook.MessageEvent;
|
||||
@@ -283,17 +283,6 @@ function resolveLineAddresses(params: {
|
||||
return { fromAddress, toAddress, originatingTo };
|
||||
}
|
||||
|
||||
function resolveLineGroupSystemPrompt(
|
||||
groups: Record<string, LineGroupConfig | undefined> | undefined,
|
||||
source: LineSourceInfoWithPeerId,
|
||||
): string | undefined {
|
||||
const entry = resolveLineGroupConfigEntry(groups, {
|
||||
groupId: source.groupId,
|
||||
roomId: source.roomId,
|
||||
});
|
||||
return normalizeOptionalString(entry?.systemPrompt);
|
||||
}
|
||||
|
||||
async function finalizeLineInboundContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
@@ -380,7 +369,12 @@ async function finalizeLineInboundContext(params: {
|
||||
OriginatingChannel: "line" as const,
|
||||
OriginatingTo: originatingTo,
|
||||
GroupSystemPrompt: params.source.isGroup
|
||||
? resolveLineGroupSystemPrompt(params.account.config.groups, params.source)
|
||||
? normalizeOptionalString(
|
||||
resolveLineGroupConfigEntry(params.account.config.groups, {
|
||||
groupId: params.source.groupId,
|
||||
roomId: params.source.roomId,
|
||||
})?.systemPrompt,
|
||||
)
|
||||
: undefined,
|
||||
InboundHistory: params.inboundHistory,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
|
||||
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
|
||||
let getLineRuntimeState: typeof import("./monitor.js").getLineRuntimeState;
|
||||
let clearLineRuntimeStateForTests: typeof import("./monitor.js").clearLineRuntimeStateForTests;
|
||||
let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
|
||||
|
||||
vi.mock("./bot.js", () => ({
|
||||
@@ -92,8 +93,13 @@ vi.mock("./template-messages.js", () => ({
|
||||
}));
|
||||
|
||||
describe("monitorLineProvider lifecycle", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeAll(async () => {
|
||||
({ monitorLineProvider, getLineRuntimeState, clearLineRuntimeStateForTests } =
|
||||
await import("./monitor.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearLineRuntimeStateForTests();
|
||||
createLineBotMock.mockReset();
|
||||
createLineBotMock.mockReturnValue({
|
||||
account: { accountId: "default" },
|
||||
@@ -105,7 +111,6 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
.mockImplementation(() => innerLineWebhookHandlerMock);
|
||||
unregisterHttpMock.mockReset();
|
||||
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
|
||||
({ monitorLineProvider, getLineRuntimeState } = await import("./monitor.js"));
|
||||
});
|
||||
|
||||
const createRouteResponse = () => {
|
||||
|
||||
@@ -97,6 +97,10 @@ export function getLineRuntimeState(accountId: string) {
|
||||
return runtimeState.get(`line:${accountId}`);
|
||||
}
|
||||
|
||||
export function clearLineRuntimeStateForTests() {
|
||||
runtimeState.clear();
|
||||
}
|
||||
|
||||
function startLineLoadingKeepalive(params: {
|
||||
userId: string;
|
||||
accountId?: string;
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
createChannelNativeOriginTargetResolver,
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeOptionalStringifiedId,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
|
||||
import {
|
||||
getMatrixExecApprovalApprovers,
|
||||
@@ -51,13 +55,8 @@ function resolveMatrixNativeTarget(raw: string): string | null {
|
||||
return target.kind === "user" ? `user:${target.id}` : `room:${target.id}`;
|
||||
}
|
||||
|
||||
function normalizeThreadId(value?: string | number | null): string | undefined {
|
||||
const trimmed = value == null ? "" : String(value).trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOriginTarget | null {
|
||||
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
|
||||
const target = resolveMatrixNativeTarget(turnSourceTo);
|
||||
if (turnSourceChannel !== "matrix" || !target) {
|
||||
@@ -65,7 +64,7 @@ function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOr
|
||||
}
|
||||
return {
|
||||
to: target,
|
||||
threadId: normalizeThreadId(request.request.turnSourceThreadId),
|
||||
threadId: normalizeOptionalStringifiedId(request.request.turnSourceThreadId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +78,7 @@ function resolveSessionMatrixOriginTarget(sessionTarget: {
|
||||
}
|
||||
return {
|
||||
to: target,
|
||||
threadId: normalizeThreadId(sessionTarget.threadId),
|
||||
threadId: normalizeOptionalStringifiedId(sessionTarget.threadId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ExecApprovalRequest,
|
||||
type ExecApprovalResolved,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { matrixNativeApprovalAdapter } from "./approval-native.js";
|
||||
import {
|
||||
buildMatrixApprovalReactionHint,
|
||||
@@ -95,11 +96,6 @@ function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string })
|
||||
return isMatrixExecApprovalClientEnabled(params);
|
||||
}
|
||||
|
||||
function normalizeThreadId(value?: string | number | null): string | undefined {
|
||||
const trimmed = value == null ? "" : String(value).trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function buildPendingApprovalContent(params: {
|
||||
request: ApprovalRequest;
|
||||
nowMs: number;
|
||||
@@ -290,7 +286,7 @@ export class MatrixExecApprovalHandler {
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const threadId = normalizeThreadId(rawTarget.threadId);
|
||||
const threadId = normalizeOptionalStringifiedId(rawTarget.threadId);
|
||||
if (target.kind === "user") {
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: this.opts.cfg as CoreConfig,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
|
||||
@@ -82,7 +83,9 @@ function matchesMatrixRequestAccount(params: {
|
||||
accountId?: string | null;
|
||||
request: ApprovalRequest;
|
||||
}): boolean {
|
||||
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
|
||||
params.request.request.turnSourceChannel,
|
||||
);
|
||||
const boundAccountId = resolveApprovalRequestChannelAccountId({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { inspectMatrixDirectRooms, persistMatrixDirectRoomMapping } from "../direct-management.js";
|
||||
import { isStrictDirectRoom } from "../direct-room.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
@@ -12,11 +13,7 @@ function normalizeTarget(raw: string): string {
|
||||
}
|
||||
|
||||
export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||
if (raw === undefined || raw === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(raw).trim();
|
||||
return trimmed ? trimmed : null;
|
||||
return normalizeOptionalStringifiedId(raw) ?? null;
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth (#4948)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
|
||||
import type {
|
||||
@@ -8,7 +9,7 @@ import type {
|
||||
} from "./runtime-api.js";
|
||||
|
||||
function normalizeLookupQuery(query: string): string {
|
||||
return query.trim().toLowerCase();
|
||||
return normalizeOptionalLowercaseString(query) ?? "";
|
||||
}
|
||||
|
||||
function findExactDirectoryMatches(
|
||||
@@ -20,9 +21,9 @@ function findExactDirectoryMatches(
|
||||
return [];
|
||||
}
|
||||
return matches.filter((match) => {
|
||||
const id = match.id.trim().toLowerCase();
|
||||
const name = match.name?.trim().toLowerCase();
|
||||
const handle = match.handle?.trim().toLowerCase();
|
||||
const id = normalizeOptionalLowercaseString(match.id);
|
||||
const name = normalizeOptionalLowercaseString(match.name);
|
||||
const handle = normalizeOptionalLowercaseString(match.handle);
|
||||
return normalized === id || normalized === name || normalized === handle;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
@@ -69,7 +70,7 @@ export async function listMattermostDirectoryGroups(
|
||||
if (!clients.length) {
|
||||
return [];
|
||||
}
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const q = normalizeLowercaseStringOrEmpty(params.query);
|
||||
const seenIds = new Set<string>();
|
||||
const entries: ChannelDirectoryEntry[] = [];
|
||||
|
||||
@@ -140,7 +141,7 @@ export async function listMattermostDirectoryPeers(
|
||||
}
|
||||
// Uses first team — multi-team setups may need iteration in the future
|
||||
const teamId = teams[0].id;
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const q = normalizeLowercaseStringOrEmpty(params.query);
|
||||
|
||||
let users: MattermostUser[];
|
||||
if (q) {
|
||||
|
||||
@@ -4,7 +4,9 @@ export {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
|
||||
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
|
||||
registerBuiltInMemoryEmbeddingProviders,
|
||||
} from "./src/memory/provider-adapters.js";
|
||||
export { createEmbeddingProvider } from "./src/memory/embeddings.js";
|
||||
export {
|
||||
resolveMemoryCacheSummary,
|
||||
resolveMemoryFtsState,
|
||||
|
||||
@@ -105,8 +105,7 @@ When `render.createDashboards` is enabled, compile also maintains report dashboa
|
||||
openclaw wiki status
|
||||
openclaw wiki doctor
|
||||
openclaw wiki init
|
||||
openclaw wiki import ./notes/alpha.md
|
||||
openclaw wiki import ~/Documents/KnowledgeVault
|
||||
openclaw wiki ingest ./notes/alpha.md
|
||||
openclaw wiki compile
|
||||
openclaw wiki lint
|
||||
openclaw wiki search "alpha"
|
||||
@@ -131,16 +130,6 @@ openclaw wiki obsidian command workspace:quick-switcher
|
||||
openclaw wiki obsidian daily
|
||||
```
|
||||
|
||||
`wiki import` is the recommended path for local files, folders, and markdown vaults like Obsidian or Logseq. It auto-detects the best import profile when possible, writes imported artifacts as source pages, and records progress in the shared task ledger for larger imports. Markdown vault imports also preserve readable note bodies plus imported tags, aliases, and link hints instead of flattening every note into a fenced blob, and compile/search now use that imported metadata to rank the right pages faster.
|
||||
|
||||
Likely ChatGPT export JSON files now auto-detect into the `chatgpt-export` importer, which splits export bundles into conversation source pages instead of collapsing the whole dump into one giant local-file note.
|
||||
|
||||
Imported aliases also work as lookup keys for `wiki_get` and metadata updates, so imported vault notes stay addressable by their original note names instead of only by generated file paths.
|
||||
|
||||
Imported markdown vault relative paths also survive into lookup, retrieval, and `## Related` reconstruction, so `projects/alpha.md` keeps behaving like note identity instead of disappearing after import.
|
||||
|
||||
`reports/import-review.md` now also flags duplicate imported titles/aliases, duplicate note bodies, and obviously low-signal notes so large vault backfills are easier to triage before you start turning them into syntheses or claims.
|
||||
|
||||
## Agent tools
|
||||
|
||||
- `wiki_status`
|
||||
@@ -170,7 +159,6 @@ Write methods:
|
||||
|
||||
- `wiki.init`
|
||||
- `wiki.compile`
|
||||
- `wiki.import`
|
||||
- `wiki.ingest`
|
||||
- `wiki.lint`
|
||||
- `wiki.bridge.import`
|
||||
|
||||
@@ -24,7 +24,6 @@ describe("memory-wiki plugin", () => {
|
||||
"wiki.init",
|
||||
"wiki.doctor",
|
||||
"wiki.compile",
|
||||
"wiki.import",
|
||||
"wiki.ingest",
|
||||
"wiki.lint",
|
||||
"wiki.bridge.import",
|
||||
|
||||
@@ -10,7 +10,7 @@ Use this skill when working inside a memory-wiki vault.
|
||||
- Use `wiki_search` to discover candidate pages when you want wiki-specific ranking/provenance, then `wiki_get` to inspect the exact page before editing or citing it.
|
||||
- Use `wiki_apply` for narrow synthesis filing and metadata updates when a tool-level mutation is enough.
|
||||
- Run `wiki_lint` after meaningful wiki updates so contradictions, provenance gaps, and open questions get surfaced before you trust the vault.
|
||||
- Use `openclaw wiki import`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
|
||||
- Use `openclaw wiki ingest`, `openclaw wiki compile`, and `openclaw wiki lint` as the default maintenance loop.
|
||||
- In `bridge` mode, run `openclaw wiki bridge import` before relying on search results if you need the latest public memory artifacts pulled in.
|
||||
- In `unsafe-local` mode, use `openclaw wiki unsafe-local import` only when the user explicitly opted into private local path access.
|
||||
- Keep generated sections inside managed markers. Do not overwrite human note blocks.
|
||||
|
||||
@@ -162,58 +162,4 @@ keep this note
|
||||
fs.readFile(path.join(rootDir, "entities", "index.md"), "utf8"),
|
||||
).resolves.toContain("[Alpha](entities/alpha.md)");
|
||||
});
|
||||
|
||||
it("resolves metadata updates through imported aliases", async () => {
|
||||
const { rootDir, config } = await createVault({
|
||||
prefix: "memory-wiki-apply-",
|
||||
});
|
||||
|
||||
const targetPath = path.join(rootDir, "sources", "alpha-import.md");
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
targetPath,
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.import.alpha",
|
||||
title: "Alpha Imported Note",
|
||||
sourceType: "markdown-vault",
|
||||
importedAliases: ["Alpha Canon"],
|
||||
status: "active",
|
||||
},
|
||||
body: `# Alpha Imported Note
|
||||
|
||||
## Notes
|
||||
<!-- openclaw:human:start -->
|
||||
keep imported note context
|
||||
<!-- openclaw:human:end -->
|
||||
`,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await applyMemoryWikiMutation({
|
||||
config,
|
||||
mutation: {
|
||||
op: "update_metadata",
|
||||
lookup: "alpha canon",
|
||||
questions: ["Need to reconcile this imported note?"],
|
||||
status: "review",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.pagePath).toBe("sources/alpha-import.md");
|
||||
|
||||
const updated = await fs.readFile(targetPath, "utf8");
|
||||
const parsed = parseWikiMarkdown(updated);
|
||||
expect(parsed.frontmatter).toMatchObject({
|
||||
id: "source.import.alpha",
|
||||
title: "Alpha Imported Note",
|
||||
importedAliases: ["Alpha Canon"],
|
||||
questions: ["Need to reconcile this imported note?"],
|
||||
status: "review",
|
||||
});
|
||||
expect(parsed.body).toContain("keep imported note context");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,50 +77,6 @@ describe("memory-wiki cli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("registers import and auto-detects markdown vaults", async () => {
|
||||
const { rootDir, config } = await createCliVault({ initialize: true });
|
||||
const sourceRoot = path.join(suiteRoot, `case-${caseIndex++}`, "vault");
|
||||
await fs.mkdir(path.join(sourceRoot, ".obsidian"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceRoot, "alpha.md"),
|
||||
`# Alpha
|
||||
|
||||
cli import body
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerWikiCli(program, config);
|
||||
|
||||
await program.parseAsync(["wiki", "import", sourceRoot, "--json"], { from: "user" });
|
||||
|
||||
const sourceEntries = await fs.readdir(path.join(rootDir, "sources"));
|
||||
expect(
|
||||
sourceEntries.filter((entry) => entry.endsWith(".md") && entry !== "index.md"),
|
||||
).toHaveLength(1);
|
||||
await expect(
|
||||
fs.readFile(path.join(rootDir, "reports", "import-review.md"), "utf8"),
|
||||
).resolves.toContain("Profile: `markdown-vault` (automatic)");
|
||||
});
|
||||
|
||||
it("prints task guidance for non-json imports", async () => {
|
||||
const { config } = await createCliVault({ initialize: true });
|
||||
const sourceRoot = path.join(suiteRoot, `case-${caseIndex++}`, "task-vault");
|
||||
await fs.mkdir(path.join(sourceRoot, ".obsidian"), { recursive: true });
|
||||
await fs.writeFile(path.join(sourceRoot, "alpha.md"), "# Alpha\n\ncli import body\n", "utf8");
|
||||
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerWikiCli(program, config);
|
||||
|
||||
await program.parseAsync(["wiki", "import", sourceRoot], { from: "user" });
|
||||
|
||||
const writes = vi.mocked(process.stdout.write).mock.calls.map((call) => String(call[0]));
|
||||
expect(writes.join("")).toContain("inspect with `openclaw tasks show ");
|
||||
});
|
||||
|
||||
it("registers apply metadata and preserves the page body", async () => {
|
||||
const { rootDir, config } = await createCliVault();
|
||||
const targetPath = path.join(rootDir, "entities", "alpha.md");
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type MemoryWikiPluginConfig,
|
||||
type ResolvedMemoryWikiConfig,
|
||||
} from "./config.js";
|
||||
import { importMemoryWikiInput, WIKI_IMPORT_PROFILE_IDS } from "./import.js";
|
||||
import { ingestMemoryWikiSource } from "./ingest.js";
|
||||
import { lintMemoryWikiVault } from "./lint.js";
|
||||
import {
|
||||
probeObsidianCli,
|
||||
@@ -54,11 +54,6 @@ type WikiIngestCommandOptions = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type WikiImportCommandOptions = {
|
||||
json?: boolean;
|
||||
profile?: string;
|
||||
};
|
||||
|
||||
type WikiSearchCommandOptions = {
|
||||
json?: boolean;
|
||||
maxResults?: number;
|
||||
@@ -190,17 +185,6 @@ function formatJsonOrText<T>(
|
||||
return json ? JSON.stringify(result, null, 2) : render(result);
|
||||
}
|
||||
|
||||
function formatWikiImportSummary(value: Awaited<ReturnType<typeof importMemoryWikiInput>>): string {
|
||||
const summary =
|
||||
`Imported ${value.inputPath} via ${value.profileId} ` +
|
||||
`(${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). ` +
|
||||
`Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`;
|
||||
if (!value.taskId) {
|
||||
return summary;
|
||||
}
|
||||
return `${summary} Task ${value.taskId}; inspect with \`openclaw tasks show ${value.taskId}\`.`;
|
||||
}
|
||||
|
||||
async function runWikiCommandWithSummary<T>(params: {
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
@@ -347,30 +331,13 @@ export async function runWikiIngest(params: {
|
||||
json: params.json,
|
||||
stdout: params.stdout,
|
||||
run: () =>
|
||||
runWikiImport({ config: params.config, inputPath: params.inputPath, title: params.title }),
|
||||
render: formatWikiImportSummary,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWikiImport(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
inputPath: string;
|
||||
profileId?: string;
|
||||
title?: string;
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
return runWikiCommandWithSummary({
|
||||
json: params.json,
|
||||
stdout: params.stdout,
|
||||
run: () =>
|
||||
importMemoryWikiInput({
|
||||
ingestMemoryWikiSource({
|
||||
config: params.config,
|
||||
inputPath: params.inputPath,
|
||||
profileId: params.profileId,
|
||||
title: params.title,
|
||||
}),
|
||||
render: formatWikiImportSummary,
|
||||
render: (value) =>
|
||||
`Ingested ${value.sourcePath} into ${value.pagePath}. Refreshed ${value.indexUpdatedFiles.length} index file${value.indexUpdatedFiles.length === 1 ? "" : "s"}.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -675,16 +642,6 @@ export function registerWikiCli(
|
||||
await runWikiLint({ config, appConfig, json: opts.json });
|
||||
});
|
||||
|
||||
wiki
|
||||
.command("import")
|
||||
.description("Import a local file or markdown vault into wiki source pages")
|
||||
.argument("<input>", "Local file or directory to import")
|
||||
.option("--profile <id>", `Override import profile (${WIKI_IMPORT_PROFILE_IDS.join(", ")})`)
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (inputPath: string, opts: WikiImportCommandOptions) => {
|
||||
await runWikiImport({ config, inputPath, profileId: opts.profile, json: opts.json });
|
||||
});
|
||||
|
||||
wiki
|
||||
.command("ingest")
|
||||
.description("Ingest a local file into the wiki sources folder")
|
||||
|
||||
@@ -85,54 +85,6 @@ describe("compileMemoryWikiVault", () => {
|
||||
).resolves.toContain('"text":"Alpha is the canonical source page."');
|
||||
});
|
||||
|
||||
it("includes imported markdown-vault metadata in the agent digest", async () => {
|
||||
const { rootDir, config } = await createVault({
|
||||
rootDir: nextCaseRoot(),
|
||||
initialize: true,
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "sources", "alpha-import.md"),
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.import.alpha",
|
||||
title: "Alpha Import",
|
||||
sourceType: "markdown-vault",
|
||||
importRelativePath: "projects/alpha.md",
|
||||
importedTags: ["project-alpha"],
|
||||
importedAliases: ["Alpha Canon"],
|
||||
importedLinkTargets: ["beta-project"],
|
||||
},
|
||||
body: "# Alpha Import\n\nImported markdown body.\n",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await compileMemoryWikiVault(config);
|
||||
|
||||
const agentDigest = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"), "utf8"),
|
||||
) as {
|
||||
pages: Array<{
|
||||
path: string;
|
||||
importRelativePath?: string;
|
||||
importedTags?: string[];
|
||||
importedAliases?: string[];
|
||||
importedLinkTargets?: string[];
|
||||
}>;
|
||||
};
|
||||
expect(agentDigest.pages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "sources/alpha-import.md",
|
||||
importRelativePath: "projects/alpha.md",
|
||||
importedTags: ["project-alpha"],
|
||||
importedAliases: ["Alpha Canon"],
|
||||
importedLinkTargets: ["beta-project"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders obsidian-friendly links when configured", async () => {
|
||||
const { rootDir, config } = await createVault({
|
||||
rootDir: nextCaseRoot(),
|
||||
@@ -395,47 +347,4 @@ describe("compileMemoryWikiVault", () => {
|
||||
fs.readFile(path.join(rootDir, "concepts", "gamma.md"), "utf8"),
|
||||
).resolves.not.toContain("### Referenced By");
|
||||
});
|
||||
|
||||
it("uses imported vault aliases and link targets for related backlinks", async () => {
|
||||
const { rootDir, config } = await createVault({
|
||||
rootDir: nextCaseRoot(),
|
||||
initialize: true,
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "sources", "alpha-import.md"),
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.import.alpha",
|
||||
title: "Alpha Note",
|
||||
sourceType: "markdown-vault",
|
||||
importRelativePath: "projects/alpha.md",
|
||||
importedAliases: ["Alpha Canon"],
|
||||
},
|
||||
body: "# Alpha Note\n",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "sources", "beta-import.md"),
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.import.beta",
|
||||
title: "Beta Note",
|
||||
sourceType: "markdown-vault",
|
||||
importedLinkTargets: ["projects/alpha.md"],
|
||||
},
|
||||
body: "# Beta Note\n",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await compileMemoryWikiVault(config);
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(rootDir, "sources", "alpha-import.md"), "utf8"),
|
||||
).resolves.toContain("[Beta Note](sources/beta-import.md)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,24 +369,13 @@ function buildPageLookupKeys(page: WikiPageSummary): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
keys.add(normalizeComparableTarget(page.relativePath));
|
||||
keys.add(normalizeComparableTarget(page.relativePath.replace(/\.md$/i, "")));
|
||||
if (page.importRelativePath) {
|
||||
keys.add(normalizeComparableTarget(page.importRelativePath));
|
||||
keys.add(normalizeComparableTarget(page.importRelativePath.replace(/\.md$/i, "")));
|
||||
}
|
||||
keys.add(normalizeComparableTarget(page.title));
|
||||
for (const alias of page.importedAliases) {
|
||||
keys.add(normalizeComparableTarget(alias));
|
||||
}
|
||||
if (page.id) {
|
||||
keys.add(normalizeComparableTarget(page.id));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getPageReferenceTargets(page: WikiPageSummary): string[] {
|
||||
return [...page.linkTargets, ...page.importedLinkTargets];
|
||||
}
|
||||
|
||||
function renderWikiPageLinks(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
pages: WikiPageSummary[];
|
||||
@@ -429,7 +418,7 @@ function buildRelatedBlockBody(params: {
|
||||
if (candidate.sourceIds.includes(params.page.id ?? "")) {
|
||||
return true;
|
||||
}
|
||||
return getPageReferenceTargets(candidate).some((target) =>
|
||||
return candidate.linkTargets.some((target) =>
|
||||
backlinkKeys.has(normalizeComparableTarget(target)),
|
||||
);
|
||||
}),
|
||||
@@ -717,13 +706,9 @@ type AgentDigestPage = {
|
||||
title: string;
|
||||
kind: WikiPageKind;
|
||||
path: string;
|
||||
importRelativePath?: string;
|
||||
sourceIds: string[];
|
||||
questions: string[];
|
||||
contradictions: string[];
|
||||
importedTags?: string[];
|
||||
importedAliases?: string[];
|
||||
importedLinkTargets?: string[];
|
||||
confidence?: number;
|
||||
freshnessLevel: WikiFreshnessLevel;
|
||||
lastTouchedAt?: string;
|
||||
@@ -856,15 +841,9 @@ function buildAgentDigest(params: {
|
||||
title: page.title,
|
||||
kind: page.kind,
|
||||
path: page.relativePath,
|
||||
...(page.importRelativePath ? { importRelativePath: page.importRelativePath } : {}),
|
||||
sourceIds: [...page.sourceIds],
|
||||
questions: [...page.questions],
|
||||
contradictions: [...page.contradictions],
|
||||
...(page.importedTags.length > 0 ? { importedTags: [...page.importedTags] } : {}),
|
||||
...(page.importedAliases.length > 0 ? { importedAliases: [...page.importedAliases] } : {}),
|
||||
...(page.importedLinkTargets.length > 0
|
||||
? { importedLinkTargets: [...page.importedLinkTargets] }
|
||||
: {}),
|
||||
...(typeof page.confidence === "number" ? { confidence: page.confidence } : {}),
|
||||
freshnessLevel: pageFreshness.level,
|
||||
...(pageFreshness.lastTouchedAt ? { lastTouchedAt: pageFreshness.lastTouchedAt } : {}),
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type ApplyMemoryWikiMutation,
|
||||
} from "./apply.js";
|
||||
import { registerMemoryWikiGatewayMethods } from "./gateway.js";
|
||||
import { importMemoryWikiInput } from "./import.js";
|
||||
import { ingestMemoryWikiSource } from "./ingest.js";
|
||||
import { searchMemoryWiki } from "./query.js";
|
||||
import { syncMemoryWikiImportedSources } from "./source-sync.js";
|
||||
@@ -25,10 +24,6 @@ vi.mock("./ingest.js", () => ({
|
||||
ingestMemoryWikiSource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./import.js", () => ({
|
||||
importMemoryWikiInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lint.js", () => ({
|
||||
lintMemoryWikiVault: vi.fn(),
|
||||
}));
|
||||
@@ -95,10 +90,6 @@ describe("memory-wiki gateway methods", () => {
|
||||
vi.mocked(ingestMemoryWikiSource).mockResolvedValue({
|
||||
pagePath: "sources/alpha-notes.md",
|
||||
} as never);
|
||||
vi.mocked(importMemoryWikiInput).mockResolvedValue({
|
||||
pagePaths: ["sources/import-markdown-vault-alpha.md"],
|
||||
profileId: "markdown-vault",
|
||||
} as never);
|
||||
vi.mocked(normalizeMemoryWikiMutationInput).mockReturnValue({
|
||||
op: "create_synthesis",
|
||||
title: "Gateway Alpha",
|
||||
@@ -200,47 +191,6 @@ describe("memory-wiki gateway methods", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards import requests over the gateway", async () => {
|
||||
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
|
||||
const { api, registerGatewayMethod } = createPluginApi();
|
||||
|
||||
registerMemoryWikiGatewayMethods({ api, config });
|
||||
const handler = findGatewayHandler(registerGatewayMethod, "wiki.import");
|
||||
if (!handler) {
|
||||
throw new Error("wiki.import handler missing");
|
||||
}
|
||||
const respond = vi.fn();
|
||||
|
||||
await handler({
|
||||
params: {
|
||||
inputPath: "/tmp/alpha-vault",
|
||||
profile: "markdown-vault",
|
||||
sessionKey: "agent:main:test",
|
||||
parentFlowId: "flow-1",
|
||||
},
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(importMemoryWikiInput).toHaveBeenCalledWith({
|
||||
config,
|
||||
inputPath: "/tmp/alpha-vault",
|
||||
profileId: "markdown-vault",
|
||||
taskContext: {
|
||||
requesterSessionKey: "agent:main:test",
|
||||
ownerKey: undefined,
|
||||
parentFlowId: "flow-1",
|
||||
parentTaskId: undefined,
|
||||
agentId: undefined,
|
||||
},
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
profileId: "markdown-vault",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies wiki mutations over the gateway", async () => {
|
||||
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
|
||||
const { api, registerGatewayMethod } = createPluginApi();
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
WIKI_SEARCH_CORPORA,
|
||||
type ResolvedMemoryWikiConfig,
|
||||
} from "./config.js";
|
||||
import { importMemoryWikiInput } from "./import.js";
|
||||
import { ingestMemoryWikiSource } from "./ingest.js";
|
||||
import { lintMemoryWikiVault } from "./lint.js";
|
||||
import {
|
||||
@@ -157,34 +156,6 @@ export function registerMemoryWikiGatewayMethods(params: {
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"wiki.import",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const inputPath = readStringParam(requestParams, "inputPath", { required: true });
|
||||
const profileId = readStringParam(requestParams, "profile");
|
||||
respond(
|
||||
true,
|
||||
await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath,
|
||||
...(profileId ? { profileId } : {}),
|
||||
taskContext: {
|
||||
requesterSessionKey: readStringParam(requestParams, "sessionKey"),
|
||||
ownerKey: readStringParam(requestParams, "ownerKey"),
|
||||
parentFlowId: readStringParam(requestParams, "parentFlowId"),
|
||||
parentTaskId: readStringParam(requestParams, "parentTaskId"),
|
||||
agentId: readStringParam(requestParams, "agentId"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"wiki.ingest",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseChatGptExportFile } from "./import-chatgpt.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir } = createMemoryWikiTestHarness();
|
||||
|
||||
describe("parseChatGptExportFile", () => {
|
||||
it("parses conversation arrays into transcript artifacts", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-export-");
|
||||
const exportPath = path.join(dir, "chatgpt-export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-alpha",
|
||||
title: "Alpha thread",
|
||||
create_time: 1_710_000_000,
|
||||
update_time: 1_710_000_100,
|
||||
mapping: {
|
||||
"2": {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
create_time: 1_710_000_020,
|
||||
content: { parts: ["hi there"] },
|
||||
},
|
||||
},
|
||||
"1": {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
create_time: 1_710_000_010,
|
||||
content: { parts: ["hello alpha"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]).toMatchObject({
|
||||
conversationId: "conv-alpha",
|
||||
title: "Alpha thread",
|
||||
relativePath: expect.stringMatching(/^alpha-thread-/),
|
||||
messageCount: 2,
|
||||
participantRoles: ["assistant", "user"],
|
||||
});
|
||||
expect(conversations[0]?.transcriptBody).toContain("### User");
|
||||
expect(conversations[0]?.transcriptBody).toContain("hello alpha");
|
||||
expect(conversations[0]?.transcriptBody).toContain("### Assistant");
|
||||
expect(conversations[0]?.transcriptBody).toContain("hi there");
|
||||
});
|
||||
|
||||
it("parses conversations envelopes", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-envelope-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify({
|
||||
conversations: [
|
||||
{
|
||||
conversation_id: "conv-envelope",
|
||||
title: "Envelope thread",
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["hello from envelope"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]).toMatchObject({
|
||||
conversationId: "conv-envelope",
|
||||
title: "Envelope thread",
|
||||
messageCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the current_node branch instead of flattening alternate branches", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-branch-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-branch",
|
||||
title: "Branch thread",
|
||||
current_node: "assistant-good",
|
||||
mapping: {
|
||||
user: {
|
||||
parent: null,
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
create_time: 1_710_000_010,
|
||||
content: { parts: ["pick the right branch"] },
|
||||
},
|
||||
},
|
||||
"assistant-bad": {
|
||||
parent: "user",
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
create_time: 1_710_000_020,
|
||||
content: { parts: ["wrong branch answer"] },
|
||||
},
|
||||
},
|
||||
"assistant-good": {
|
||||
parent: "user",
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
create_time: 1_710_000_030,
|
||||
content: { parts: ["correct branch answer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]).toMatchObject({
|
||||
conversationId: "conv-branch",
|
||||
messageCount: 2,
|
||||
});
|
||||
expect(conversations[0]?.transcriptBody).toContain("pick the right branch");
|
||||
expect(conversations[0]?.transcriptBody).toContain("correct branch answer");
|
||||
expect(conversations[0]?.transcriptBody).not.toContain("wrong branch answer");
|
||||
});
|
||||
|
||||
it("extracts readable text from object-shaped content parts", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-rich-parts-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-rich",
|
||||
title: "Rich parts thread",
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "first rich paragraph" },
|
||||
{ content: { text: "second rich paragraph" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]?.transcriptBody).toContain("first rich paragraph");
|
||||
expect(conversations[0]?.transcriptBody).toContain("second rich paragraph");
|
||||
});
|
||||
|
||||
it("preserves lineage order when current_node messages have missing timestamps", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-no-times-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-no-times",
|
||||
title: "No times thread",
|
||||
current_node: "assistant-final",
|
||||
mapping: {
|
||||
user: {
|
||||
parent: null,
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["first turn"] },
|
||||
},
|
||||
},
|
||||
"assistant-final": {
|
||||
parent: "user",
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
content: { parts: ["second turn"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]?.transcriptBody.indexOf("first turn")).toBeLessThan(
|
||||
conversations[0]?.transcriptBody.indexOf("second turn") ?? -1,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves source order when fallback transcript messages share the same timestamp", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-same-times-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-same-times",
|
||||
title: "Same times thread",
|
||||
mapping: {
|
||||
"1": {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
create_time: 1_710_000_010,
|
||||
content: { parts: ["first exported message"] },
|
||||
},
|
||||
},
|
||||
"2": {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
create_time: 1_710_000_010,
|
||||
content: { parts: ["second exported message"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]?.transcriptBody.indexOf("first exported message")).toBeLessThan(
|
||||
conversations[0]?.transcriptBody.indexOf("second exported message") ?? -1,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips hidden and tool messages from imported transcripts", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-hidden-tool-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-hidden-tool",
|
||||
title: "Hidden tool thread",
|
||||
mapping: {
|
||||
visible: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["visible user turn"] },
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
metadata: { is_visually_hidden_from_conversation: true },
|
||||
content: { parts: ["hidden assistant turn"] },
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
message: {
|
||||
author: { role: "tool" },
|
||||
content: { parts: ["tool output"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]?.transcriptBody).toContain("visible user turn");
|
||||
expect(conversations[0]?.transcriptBody).not.toContain("hidden assistant turn");
|
||||
expect(conversations[0]?.transcriptBody).not.toContain("tool output");
|
||||
});
|
||||
|
||||
it("drops conversations with no readable visible messages", async () => {
|
||||
const dir = await createTempDir("memory-wiki-chatgpt-empty-visible-");
|
||||
const exportPath = path.join(dir, "export.json");
|
||||
await fs.writeFile(
|
||||
exportPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-empty",
|
||||
title: "Empty visible thread",
|
||||
mapping: {
|
||||
hidden: {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
metadata: { is_visually_hidden_from_conversation: true },
|
||||
content: { parts: ["hidden assistant turn"] },
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
message: {
|
||||
author: { role: "tool" },
|
||||
content: { parts: ["tool output"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-visible",
|
||||
title: "Visible thread",
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["keep me"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const conversations = await parseChatGptExportFile(exportPath);
|
||||
expect(conversations).toHaveLength(1);
|
||||
expect(conversations[0]).toMatchObject({
|
||||
conversationId: "conv-visible",
|
||||
title: "Visible thread",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { slugifyWikiSegment } from "./markdown.js";
|
||||
|
||||
type ChatGptMessage = {
|
||||
role: string;
|
||||
text: string;
|
||||
sortTime: number;
|
||||
sourceIndex: number;
|
||||
};
|
||||
|
||||
type ChatGptMappingNode = {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
message: ChatGptMessage | null;
|
||||
};
|
||||
|
||||
export type ChatGptExportConversation = {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
relativePath: string;
|
||||
transcriptBody: string;
|
||||
messageCount: number;
|
||||
participantRoles: string[];
|
||||
conversationCreatedAt?: string;
|
||||
conversationUpdatedAt?: string;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): { iso?: string; sortTime: number } {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const ms = value > 1_000_000_000_000 ? value : value * 1000;
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime())
|
||||
? { sortTime: 0 }
|
||||
: { iso: date.toISOString(), sortTime: ms };
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? { sortTime: 0 }
|
||||
: { iso: date.toISOString(), sortTime: date.getTime() };
|
||||
}
|
||||
return { sortTime: 0 };
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): string {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatRoleHeading(role: string): string {
|
||||
return role
|
||||
.split(/[^a-z0-9]+/i)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function collectTextFragments(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => collectTextFragments(entry));
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
for (const key of ["text", "parts", "content"]) {
|
||||
if (key in record) {
|
||||
const fragments = collectTextFragments(record[key]);
|
||||
if (fragments.length > 0) {
|
||||
return fragments;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractMessageText(contentValue: unknown): string {
|
||||
const content = asRecord(contentValue);
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
if (Array.isArray(content.parts)) {
|
||||
return collectTextFragments(content.parts).join("\n\n");
|
||||
}
|
||||
if (typeof content.text === "string" && content.text.trim()) {
|
||||
return content.text.trim();
|
||||
}
|
||||
if (Array.isArray(content.text)) {
|
||||
return collectTextFragments(content.text).join("\n\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function shouldImportConversationMessage(params: {
|
||||
role: string;
|
||||
message: Record<string, unknown>;
|
||||
}): boolean {
|
||||
if (params.role === "tool") {
|
||||
return false;
|
||||
}
|
||||
const metadata = asRecord(params.message.metadata);
|
||||
if (metadata?.is_visually_hidden_from_conversation === true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function compareChatGptMessages(left: ChatGptMessage, right: ChatGptMessage): number {
|
||||
if (left.sortTime !== right.sortTime) {
|
||||
if (left.sortTime === 0) {
|
||||
return 1;
|
||||
}
|
||||
if (right.sortTime === 0) {
|
||||
return -1;
|
||||
}
|
||||
return left.sortTime - right.sortTime;
|
||||
}
|
||||
return left.sourceIndex - right.sourceIndex;
|
||||
}
|
||||
|
||||
function extractConversationMessages(mappingValue: unknown): ChatGptMessage[] {
|
||||
const mapping = asRecord(mappingValue);
|
||||
if (!mapping) {
|
||||
return [];
|
||||
}
|
||||
return Object.values(mapping)
|
||||
.flatMap((entry, sourceIndex) => {
|
||||
const node = asRecord(entry);
|
||||
const message = asRecord(node?.message);
|
||||
if (!message) {
|
||||
return [];
|
||||
}
|
||||
const text = extractMessageText(message.content);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const author = asRecord(message.author);
|
||||
const role = normalizeRole(author?.role ?? author?.name);
|
||||
if (!shouldImportConversationMessage({ role, message })) {
|
||||
return [];
|
||||
}
|
||||
const { sortTime } = normalizeTimestamp(message.create_time ?? node?.create_time);
|
||||
return [
|
||||
{
|
||||
role,
|
||||
text,
|
||||
sortTime,
|
||||
sourceIndex,
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted(compareChatGptMessages);
|
||||
}
|
||||
|
||||
function extractConversationMappingNodes(mappingValue: unknown): ChatGptMappingNode[] {
|
||||
const mapping = asRecord(mappingValue);
|
||||
if (!mapping) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(mapping).map(([id, entry], sourceIndex) => {
|
||||
const node = asRecord(entry);
|
||||
const message = asRecord(node?.message);
|
||||
const text = extractMessageText(message?.content);
|
||||
const author = asRecord(message?.author);
|
||||
const role = normalizeRole(author?.role ?? author?.name);
|
||||
const { sortTime } = normalizeTimestamp(message?.create_time ?? node?.create_time);
|
||||
return {
|
||||
id,
|
||||
parentId:
|
||||
typeof node?.parent === "string" && node.parent.trim() ? node.parent.trim() : undefined,
|
||||
message:
|
||||
text && message && shouldImportConversationMessage({ role, message })
|
||||
? {
|
||||
role,
|
||||
text,
|
||||
sortTime,
|
||||
sourceIndex,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractCurrentConversationMessages(params: {
|
||||
mappingValue: unknown;
|
||||
currentNodeId?: unknown;
|
||||
}): ChatGptMessage[] {
|
||||
const nodes = extractConversationMappingNodes(params.mappingValue);
|
||||
if (
|
||||
nodes.length === 0 ||
|
||||
typeof params.currentNodeId !== "string" ||
|
||||
!params.currentNodeId.trim()
|
||||
) {
|
||||
return extractConversationMessages(params.mappingValue);
|
||||
}
|
||||
|
||||
const byId = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
const lineage: ChatGptMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined = params.currentNodeId.trim();
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const node = byId.get(cursor);
|
||||
if (!node) {
|
||||
break;
|
||||
}
|
||||
if (node.message) {
|
||||
lineage.push(node.message);
|
||||
}
|
||||
cursor = node.parentId;
|
||||
}
|
||||
if (lineage.length === 0) {
|
||||
return extractConversationMessages(params.mappingValue);
|
||||
}
|
||||
return lineage.toReversed();
|
||||
}
|
||||
|
||||
function renderTranscriptBody(messages: ChatGptMessage[]): string {
|
||||
if (messages.length === 0) {
|
||||
return "_No readable ChatGPT transcript messages were found in this export conversation._";
|
||||
}
|
||||
return messages
|
||||
.flatMap((message) => [`### ${formatRoleHeading(message.role)}`, "", message.text, ""])
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveConversationRecords(parsed: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.flatMap((entry) => {
|
||||
const record = asRecord(entry);
|
||||
return record ? [record] : [];
|
||||
});
|
||||
}
|
||||
const envelope = asRecord(parsed);
|
||||
if (Array.isArray(envelope?.conversations)) {
|
||||
return envelope.conversations.flatMap((entry) => {
|
||||
const record = asRecord(entry);
|
||||
return record ? [record] : [];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveConversationId(record: Record<string, unknown>, index: number): string {
|
||||
for (const key of ["id", "conversation_id", "conversationId"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return `conversation-${index + 1}`;
|
||||
}
|
||||
|
||||
export async function parseChatGptExportFile(
|
||||
inputPath: string,
|
||||
): Promise<ChatGptExportConversation[]> {
|
||||
const raw = await fs.readFile(inputPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const records = resolveConversationRecords(parsed);
|
||||
if (records.length === 0) {
|
||||
throw new Error(`No ChatGPT conversations found in export: ${inputPath}`);
|
||||
}
|
||||
|
||||
const conversations = records.flatMap((record, index) => {
|
||||
const conversationId = resolveConversationId(record, index);
|
||||
const title =
|
||||
(typeof record.title === "string" && record.title.trim()) || `Conversation ${index + 1}`;
|
||||
const created = normalizeTimestamp(record.create_time);
|
||||
const updated = normalizeTimestamp(record.update_time);
|
||||
const messages = extractCurrentConversationMessages({
|
||||
mappingValue: record.mapping,
|
||||
currentNodeId: record.current_node,
|
||||
});
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const participantRoles = [...new Set(messages.map((message) => message.role))].toSorted();
|
||||
const relativeSlug = slugifyWikiSegment(title);
|
||||
const idHash = createHash("sha1").update(conversationId).digest("hex").slice(0, 8);
|
||||
return [
|
||||
{
|
||||
conversationId,
|
||||
title,
|
||||
relativePath: `${relativeSlug}-${idHash}.md`,
|
||||
transcriptBody: renderTranscriptBody(messages),
|
||||
messageCount: messages.length,
|
||||
participantRoles,
|
||||
...(created.iso ? { conversationCreatedAt: created.iso } : {}),
|
||||
...(updated.iso ? { conversationUpdatedAt: updated.iso } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (conversations.length === 0) {
|
||||
throw new Error(`No readable ChatGPT conversations found in export: ${inputPath}`);
|
||||
}
|
||||
return conversations;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectChatGptExportSample } from "./import-profile-detect.js";
|
||||
|
||||
describe("detectChatGptExportSample", () => {
|
||||
it("detects likely ChatGPT exports by filename", () => {
|
||||
expect(
|
||||
detectChatGptExportSample({
|
||||
inputPath: "/tmp/chatgpt-export.json",
|
||||
sample: JSON.stringify({ anything: true }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects likely ChatGPT conversation exports by JSON structure", () => {
|
||||
expect(
|
||||
detectChatGptExportSample({
|
||||
inputPath: "/tmp/export.json",
|
||||
sample: JSON.stringify([
|
||||
{
|
||||
title: "Alpha thread",
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["hello"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag generic JSON files as ChatGPT exports", () => {
|
||||
expect(
|
||||
detectChatGptExportSample({
|
||||
inputPath: "/tmp/project-notes.json",
|
||||
sample: JSON.stringify({
|
||||
title: "Alpha notes",
|
||||
body: "just a normal note export",
|
||||
}),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
function looksLikeChatGptConversationRecord(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const title = record.title;
|
||||
const mapping = record.mapping;
|
||||
return typeof title === "string" && !!mapping && typeof mapping === "object";
|
||||
}
|
||||
|
||||
function looksLikeChatGptConversationsEnvelope(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const conversations = record.conversations;
|
||||
return (
|
||||
Array.isArray(conversations) &&
|
||||
conversations.some((entry) => looksLikeChatGptConversationRecord(entry))
|
||||
);
|
||||
}
|
||||
|
||||
export function detectChatGptExportSample(params: { inputPath: string; sample: string }): boolean {
|
||||
const basename = path.basename(params.inputPath).toLowerCase();
|
||||
if (basename.includes("chatgpt")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(params.sample) as unknown;
|
||||
return (
|
||||
(Array.isArray(parsed) &&
|
||||
parsed.some((entry) => looksLikeChatGptConversationRecord(entry))) ||
|
||||
looksLikeChatGptConversationsEnvelope(parsed)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectChatGptExportFile(inputPath: string): Promise<boolean> {
|
||||
const ext = path.extname(inputPath).toLowerCase();
|
||||
if (ext !== ".json") {
|
||||
return false;
|
||||
}
|
||||
const sample = await fs.readFile(inputPath, "utf8").catch(() => null);
|
||||
if (!sample) {
|
||||
return false;
|
||||
}
|
||||
return detectChatGptExportSample({ inputPath, sample });
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildImportBodyDuplicateClusters,
|
||||
buildImportDuplicateClusters,
|
||||
buildImportReviewBody,
|
||||
buildLowSignalImportEntries,
|
||||
type ImportReviewEntry,
|
||||
} from "./import-review.js";
|
||||
|
||||
const entries: ImportReviewEntry[] = [
|
||||
{
|
||||
title: "Shared Title",
|
||||
relativePath: "alpha.md",
|
||||
pagePath: "sources/alpha.md",
|
||||
importedAliases: ["Shared Alias"],
|
||||
importedTags: ["alpha"],
|
||||
bodyTextLength: 120,
|
||||
nonEmptyLineCount: 5,
|
||||
bodyFingerprint: "body-dup-1",
|
||||
},
|
||||
{
|
||||
title: "Shared Title",
|
||||
relativePath: "beta.md",
|
||||
pagePath: "sources/beta.md",
|
||||
importedAliases: ["Shared Alias"],
|
||||
importedTags: ["beta"],
|
||||
bodyTextLength: 110,
|
||||
nonEmptyLineCount: 4,
|
||||
bodyFingerprint: "body-dup-1",
|
||||
},
|
||||
{
|
||||
title: "Tiny",
|
||||
relativePath: "tiny.md",
|
||||
pagePath: "sources/tiny.md",
|
||||
importedAliases: [],
|
||||
importedTags: [],
|
||||
bodyTextLength: 12,
|
||||
nonEmptyLineCount: 2,
|
||||
bodyFingerprint: "tiny-body",
|
||||
},
|
||||
];
|
||||
|
||||
describe("import-review", () => {
|
||||
it("clusters duplicate imported titles and aliases", () => {
|
||||
const clusters = buildImportDuplicateClusters(entries);
|
||||
expect(clusters[0]).toMatchObject({
|
||||
label: "Shared Alias",
|
||||
entryCount: 2,
|
||||
});
|
||||
expect(clusters[1]).toMatchObject({
|
||||
label: "Shared Title",
|
||||
entryCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags low-signal imported entries", () => {
|
||||
expect(buildLowSignalImportEntries(entries)).toEqual([
|
||||
expect.objectContaining({ relativePath: "tiny.md" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("clusters duplicate imported note bodies", () => {
|
||||
expect(buildImportBodyDuplicateClusters(entries)).toEqual([
|
||||
expect.objectContaining({
|
||||
fingerprint: "body-dup-1",
|
||||
entryCount: 2,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders duplicate and low-signal sections in the review body", () => {
|
||||
const body = buildImportReviewBody({
|
||||
inputPath: "/tmp/vault",
|
||||
profileId: "markdown-vault",
|
||||
profileResolution: "automatic",
|
||||
artifactCount: 3,
|
||||
importedCount: 3,
|
||||
updatedCount: 0,
|
||||
skippedCount: 0,
|
||||
removedCount: 0,
|
||||
pagePaths: entries.map((entry) => entry.pagePath),
|
||||
reviewEntries: entries,
|
||||
});
|
||||
|
||||
expect(body).toContain("## Duplicate Title/Alias Clusters");
|
||||
expect(body).toContain("`Shared Title` (2 notes)");
|
||||
expect(body).toContain("## Duplicate Body Clusters");
|
||||
expect(body).toContain("`body-dup-1` (2 notes)");
|
||||
expect(body).toContain("## Low-Signal Sources");
|
||||
expect(body).toContain("`tiny.md` (Tiny): 2 non-empty lines, 12 characters");
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
export type ImportReviewEntry = {
|
||||
title: string;
|
||||
relativePath: string;
|
||||
pagePath: string;
|
||||
importedAliases: string[];
|
||||
importedTags: string[];
|
||||
bodyTextLength: number;
|
||||
nonEmptyLineCount: number;
|
||||
bodyFingerprint?: string;
|
||||
};
|
||||
|
||||
function normalizeImportReviewKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function buildImportDuplicateClusters(reviewEntries: ImportReviewEntry[]): Array<{
|
||||
label: string;
|
||||
entryCount: number;
|
||||
entries: ImportReviewEntry[];
|
||||
}> {
|
||||
const clusters = new Map<string, { label: string; entries: ImportReviewEntry[] }>();
|
||||
for (const entry of reviewEntries) {
|
||||
for (const label of [entry.title, ...entry.importedAliases]) {
|
||||
const normalized = normalizeImportReviewKey(label);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const current = clusters.get(normalized) ?? { label, entries: [] };
|
||||
if (!current.entries.some((candidate) => candidate.pagePath === entry.pagePath)) {
|
||||
current.entries.push(entry);
|
||||
}
|
||||
clusters.set(normalized, current);
|
||||
}
|
||||
}
|
||||
return [...clusters.values()]
|
||||
.filter((cluster) => cluster.entries.length > 1)
|
||||
.map((cluster) => ({
|
||||
label: cluster.label,
|
||||
entryCount: cluster.entries.length,
|
||||
entries: [...cluster.entries].toSorted((left, right) =>
|
||||
left.relativePath.localeCompare(right.relativePath),
|
||||
),
|
||||
}))
|
||||
.toSorted((left, right) => {
|
||||
if (left.entryCount !== right.entryCount) {
|
||||
return right.entryCount - left.entryCount;
|
||||
}
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLowSignalImportEntries(
|
||||
reviewEntries: ImportReviewEntry[],
|
||||
): ImportReviewEntry[] {
|
||||
return reviewEntries
|
||||
.filter((entry) => entry.bodyTextLength < 80 || entry.nonEmptyLineCount <= 2)
|
||||
.toSorted((left, right) => left.relativePath.localeCompare(right.relativePath));
|
||||
}
|
||||
|
||||
export function buildImportBodyDuplicateClusters(reviewEntries: ImportReviewEntry[]): Array<{
|
||||
fingerprint: string;
|
||||
entryCount: number;
|
||||
entries: ImportReviewEntry[];
|
||||
}> {
|
||||
const clusters = new Map<string, ImportReviewEntry[]>();
|
||||
for (const entry of reviewEntries) {
|
||||
const fingerprint = entry.bodyFingerprint?.trim();
|
||||
if (!fingerprint || entry.bodyTextLength < 80) {
|
||||
continue;
|
||||
}
|
||||
const current = clusters.get(fingerprint) ?? [];
|
||||
current.push(entry);
|
||||
clusters.set(fingerprint, current);
|
||||
}
|
||||
return [...clusters.entries()]
|
||||
.filter(([, entries]) => entries.length > 1)
|
||||
.map(([fingerprint, entries]) => ({
|
||||
fingerprint,
|
||||
entryCount: entries.length,
|
||||
entries: [...entries].toSorted((left, right) =>
|
||||
left.relativePath.localeCompare(right.relativePath),
|
||||
),
|
||||
}))
|
||||
.toSorted((left, right) => {
|
||||
if (left.entryCount !== right.entryCount) {
|
||||
return right.entryCount - left.entryCount;
|
||||
}
|
||||
return left.fingerprint.localeCompare(right.fingerprint);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildImportReviewBody(params: {
|
||||
inputPath: string;
|
||||
profileId: string;
|
||||
profileResolution: "automatic" | "explicit";
|
||||
artifactCount: number;
|
||||
importedCount: number;
|
||||
updatedCount: number;
|
||||
skippedCount: number;
|
||||
removedCount: number;
|
||||
pagePaths: string[];
|
||||
reviewEntries: ImportReviewEntry[];
|
||||
}): string {
|
||||
const lines = [
|
||||
"# Import Review",
|
||||
"",
|
||||
"## Summary",
|
||||
`- Input: \`${params.inputPath}\``,
|
||||
`- Profile: \`${params.profileId}\` (${params.profileResolution})`,
|
||||
`- Artifacts discovered: ${params.artifactCount}`,
|
||||
`- Imported: ${params.importedCount}`,
|
||||
`- Updated: ${params.updatedCount}`,
|
||||
`- Unchanged: ${params.skippedCount}`,
|
||||
`- Removed: ${params.removedCount}`,
|
||||
"",
|
||||
"## Imported Pages",
|
||||
];
|
||||
if (params.pagePaths.length === 0) {
|
||||
lines.push("- No importable pages were written.");
|
||||
} else {
|
||||
for (const pagePath of params.pagePaths) {
|
||||
lines.push(`- ${pagePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateClusters = buildImportDuplicateClusters(params.reviewEntries);
|
||||
lines.push("", "## Duplicate Title/Alias Clusters");
|
||||
if (duplicateClusters.length === 0) {
|
||||
lines.push("- No duplicate title or alias clusters detected.");
|
||||
} else {
|
||||
for (const cluster of duplicateClusters) {
|
||||
lines.push(
|
||||
`- \`${cluster.label}\` (${cluster.entryCount} notes): ${cluster.entries
|
||||
.map((entry) => `\`${entry.relativePath}\``)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyDuplicateClusters = buildImportBodyDuplicateClusters(params.reviewEntries);
|
||||
lines.push("", "## Duplicate Body Clusters");
|
||||
if (bodyDuplicateClusters.length === 0) {
|
||||
lines.push("- No duplicate imported note bodies detected.");
|
||||
} else {
|
||||
for (const cluster of bodyDuplicateClusters) {
|
||||
lines.push(
|
||||
`- \`${cluster.fingerprint}\` (${cluster.entryCount} notes): ${cluster.entries
|
||||
.map((entry) => `\`${entry.relativePath}\``)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lowSignalEntries = buildLowSignalImportEntries(params.reviewEntries);
|
||||
lines.push("", "## Low-Signal Sources");
|
||||
if (lowSignalEntries.length === 0) {
|
||||
lines.push("- No obviously low-signal imported sources detected.");
|
||||
} else {
|
||||
for (const entry of lowSignalEntries) {
|
||||
lines.push(
|
||||
`- \`${entry.relativePath}\` (${entry.title}): ${entry.nonEmptyLineCount} non-empty lines, ${entry.bodyTextLength} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { importMemoryWikiInput } from "./import.js";
|
||||
import { parseWikiMarkdown } from "./markdown.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir, createVault } = createMemoryWikiTestHarness();
|
||||
|
||||
describe("memory-wiki import", () => {
|
||||
it("imports a single local file through the unified import runner", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const sourceRoot = await createTempDir("memory-wiki-import-file-");
|
||||
const sourcePath = path.join(sourceRoot, "alpha-notes.md");
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
`# Alpha Notes
|
||||
|
||||
alpha body
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: sourcePath,
|
||||
});
|
||||
|
||||
expect(result.profileId).toBe("local-file");
|
||||
expect(result.importedCount).toBe(1);
|
||||
await expect(fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8")).resolves.toContain(
|
||||
"sourceType: local-file",
|
||||
);
|
||||
await expect(fs.readFile(path.join(rootDir, result.reportPath), "utf8")).resolves.toContain(
|
||||
"Profile: `local-file` (automatic)",
|
||||
);
|
||||
expect(result.taskId).toBeTruthy();
|
||||
expect(result.runId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("auto-detects markdown vaults and skips vault metadata directories", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const vaultPath = await createTempDir("memory-wiki-import-vault-");
|
||||
await fs.mkdir(path.join(vaultPath, ".obsidian"), { recursive: true });
|
||||
await fs.mkdir(path.join(vaultPath, "projects"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(vaultPath, "alpha.md"),
|
||||
`---
|
||||
tags:
|
||||
- alpha
|
||||
aliases:
|
||||
- Alpha Note
|
||||
---
|
||||
|
||||
# Alpha
|
||||
|
||||
alpha body with [[Beta Project|Beta]] and [Plan](projects/beta.md).
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(vaultPath, "projects", "beta.md"),
|
||||
"# Beta\n\nbeta body\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(vaultPath, ".obsidian", "workspace.json"), "{}", "utf8");
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: vaultPath,
|
||||
});
|
||||
|
||||
expect(result.profileId).toBe("markdown-vault");
|
||||
expect(result.artifactCount).toBe(2);
|
||||
expect(result.importedCount).toBe(2);
|
||||
const importedPage = await fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8");
|
||||
const parsedImportedPage = parseWikiMarkdown(importedPage);
|
||||
expect(parsedImportedPage.frontmatter).toMatchObject({
|
||||
sourceType: "markdown-vault",
|
||||
importRelativePath: "alpha.md",
|
||||
importedTags: ["alpha"],
|
||||
importedAliases: ["Alpha Note"],
|
||||
importedLinkTargets: ["Beta Project", "projects/beta.md"],
|
||||
});
|
||||
expect(parsedImportedPage.body).toContain("alpha body with Beta and Plan (projects/beta.md).");
|
||||
expect(parsedImportedPage.body).not.toContain("[[Beta Project|Beta]]");
|
||||
const sourceEntries = await fs.readdir(path.join(rootDir, "sources"));
|
||||
expect(
|
||||
sourceEntries.filter((entry) => entry.endsWith(".md") && entry !== "index.md"),
|
||||
).toHaveLength(2);
|
||||
await expect(fs.readFile(path.join(rootDir, result.reportPath), "utf8")).resolves.toContain(
|
||||
"Profile: `markdown-vault` (automatic)",
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-detects logseq vaults and skips logseq metadata directories", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const vaultPath = await createTempDir("memory-wiki-import-logseq-");
|
||||
await fs.mkdir(path.join(vaultPath, "logseq"), { recursive: true });
|
||||
await fs.writeFile(path.join(vaultPath, "alpha.md"), "# Alpha\n\nlogseq vault body\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(vaultPath, "logseq", "settings.md"),
|
||||
"# Settings\n\nskip me\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: vaultPath,
|
||||
});
|
||||
|
||||
expect(result.profileId).toBe("markdown-vault");
|
||||
expect(result.artifactCount).toBe(1);
|
||||
expect(result.importedCount).toBe(1);
|
||||
await expect(fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8")).resolves.toContain(
|
||||
"logseq vault body",
|
||||
);
|
||||
});
|
||||
|
||||
it("imports chatgpt-export files explicitly as conversation sources", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const sourceRoot = await createTempDir("memory-wiki-import-placeholder-");
|
||||
const sourcePath = path.join(sourceRoot, "chatgpt-export.json");
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-alpha",
|
||||
title: "Alpha thread",
|
||||
create_time: 1_710_000_000,
|
||||
update_time: 1_710_000_100,
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["hello alpha"] },
|
||||
},
|
||||
},
|
||||
branch: {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
content: { parts: ["hi there"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: sourcePath,
|
||||
profileId: "chatgpt-export",
|
||||
});
|
||||
|
||||
expect(result.profileId).toBe("chatgpt-export");
|
||||
expect(result.importedCount).toBe(1);
|
||||
const importedPage = await fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8");
|
||||
const parsedImportedPage = parseWikiMarkdown(importedPage);
|
||||
expect(parsedImportedPage.frontmatter).toMatchObject({
|
||||
sourceType: "chatgpt-export",
|
||||
importProfile: "chatgpt-export",
|
||||
importedTags: ["chatgpt-export"],
|
||||
importedAliases: ["conv-alpha"],
|
||||
});
|
||||
expect(parsedImportedPage.body).toContain("## Conversation Transcript");
|
||||
expect(parsedImportedPage.body).toContain("### User");
|
||||
expect(parsedImportedPage.body).toContain("hello alpha");
|
||||
expect(parsedImportedPage.body).toContain("### Assistant");
|
||||
expect(parsedImportedPage.body).toContain("hi there");
|
||||
});
|
||||
|
||||
it("auto-detects likely ChatGPT export files into the chatgpt-export importer", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const sourceRoot = await createTempDir("memory-wiki-import-chatgpt-auto-");
|
||||
const sourcePath = path.join(sourceRoot, "export.json");
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
title: "Alpha thread",
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["hello"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: sourcePath,
|
||||
});
|
||||
|
||||
expect(result.profileId).toBe("chatgpt-export");
|
||||
expect(result.importedCount).toBe(1);
|
||||
await expect(fs.readFile(path.join(rootDir, result.reportPath), "utf8")).resolves.toContain(
|
||||
"Profile: `chatgpt-export` (automatic)",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips hidden-only ChatGPT conversations during import", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const sourceRoot = await createTempDir("memory-wiki-import-chatgpt-skip-empty-");
|
||||
const sourcePath = path.join(sourceRoot, "export.json");
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "conv-hidden",
|
||||
title: "Hidden thread",
|
||||
mapping: {
|
||||
hidden: {
|
||||
message: {
|
||||
author: { role: "assistant" },
|
||||
metadata: { is_visually_hidden_from_conversation: true },
|
||||
content: { parts: ["hidden turn"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-visible",
|
||||
title: "Visible thread",
|
||||
mapping: {
|
||||
root: {
|
||||
message: {
|
||||
author: { role: "user" },
|
||||
content: { parts: ["visible turn"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: sourcePath,
|
||||
profileId: "chatgpt-export",
|
||||
});
|
||||
|
||||
expect(result.profileId).toBe("chatgpt-export");
|
||||
expect(result.artifactCount).toBe(1);
|
||||
expect(result.importedCount).toBe(1);
|
||||
expect(result.pagePaths).toHaveLength(1);
|
||||
const importedPage = await fs.readFile(path.join(rootDir, result.pagePaths[0]), "utf8");
|
||||
expect(importedPage).toContain("visible turn");
|
||||
expect(importedPage).not.toContain("hidden turn");
|
||||
});
|
||||
|
||||
it("writes duplicate and low-signal review sections for vault imports", async () => {
|
||||
const { rootDir, config } = await createVault({ initialize: true });
|
||||
const vaultPath = await createTempDir("memory-wiki-import-review-");
|
||||
await fs.mkdir(path.join(vaultPath, ".obsidian"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(vaultPath, "alpha.md"),
|
||||
`---
|
||||
aliases:
|
||||
- Shared Alias
|
||||
---
|
||||
|
||||
# Shared Title
|
||||
|
||||
This imported note has enough substance to avoid the low-signal bucket.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(vaultPath, "beta.md"),
|
||||
`---
|
||||
aliases:
|
||||
- Shared Alias
|
||||
---
|
||||
|
||||
# Shared Title
|
||||
|
||||
Another imported note with enough text to trigger duplicate clustering.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(vaultPath, "tiny.md"), "# Tiny\n\nok\n", "utf8");
|
||||
|
||||
const result = await importMemoryWikiInput({
|
||||
config,
|
||||
inputPath: vaultPath,
|
||||
});
|
||||
|
||||
const report = await fs.readFile(path.join(rootDir, result.reportPath), "utf8");
|
||||
expect(report).toContain("## Duplicate Title/Alias Clusters");
|
||||
expect(report).toContain("`Shared Title` (2 notes)");
|
||||
expect(report).toContain("## Low-Signal Sources");
|
||||
expect(report).toContain("`tiny.md` (Tiny):");
|
||||
});
|
||||
});
|
||||
@@ -1,905 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
completePluginTaskRun,
|
||||
createPluginTaskRun,
|
||||
failPluginTaskRun,
|
||||
recordPluginTaskProgress,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { normalizeSingleOrTrimmedStringList } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { compileMemoryWikiVault, type CompileMemoryWikiResult } from "./compile.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { parseChatGptExportFile, type ChatGptExportConversation } from "./import-chatgpt.js";
|
||||
import { detectChatGptExportFile } from "./import-profile-detect.js";
|
||||
import { buildImportReviewBody, type ImportReviewEntry } from "./import-review.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import {
|
||||
extractWikiLinks,
|
||||
extractTitleFromMarkdown,
|
||||
parseWikiMarkdown,
|
||||
renderMarkdownFence,
|
||||
renderWikiMarkdown,
|
||||
slugifyWikiSegment,
|
||||
} from "./markdown.js";
|
||||
import { writeImportedSourcePage } from "./source-page-shared.js";
|
||||
import { pathExists, resolveArtifactKey } from "./source-path-shared.js";
|
||||
import {
|
||||
pruneImportedSourceEntries,
|
||||
readMemoryWikiSourceSyncState,
|
||||
writeMemoryWikiSourceSyncState,
|
||||
} from "./source-sync-state.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
const DIRECTORY_TEXT_EXTENSIONS = new Set([
|
||||
".json",
|
||||
".jsonl",
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
".yaml",
|
||||
".yml",
|
||||
]);
|
||||
const MARKDOWN_VAULT_EXTENSIONS = new Set([".md", ".markdown"]);
|
||||
const MARKDOWN_VAULT_MARKERS = [".obsidian", "logseq"] as const;
|
||||
const IMPORT_TASK_KIND = "memory-wiki-import";
|
||||
const IMPORT_OWNER_KEY = "memory-wiki:import";
|
||||
const IMPORT_REVIEW_PATH = "reports/import-review.md";
|
||||
const IMPORTED_OBSIDIAN_LINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
||||
const IMPORTED_MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
|
||||
export const WIKI_IMPORT_PROFILE_IDS = [
|
||||
"local-file",
|
||||
"directory-text",
|
||||
"markdown-vault",
|
||||
"chatgpt-export",
|
||||
] as const;
|
||||
|
||||
export type WikiImportProfileId = (typeof WIKI_IMPORT_PROFILE_IDS)[number];
|
||||
|
||||
type WikiImportArtifact = {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
profileId: Exclude<WikiImportProfileId, "chatgpt-export">;
|
||||
importRootPath: string;
|
||||
sourceType: string;
|
||||
};
|
||||
|
||||
type PreparedImportArtifact = {
|
||||
title: string;
|
||||
importedTags: string[];
|
||||
importedAliases: string[];
|
||||
importedLinkTargets: string[];
|
||||
renderedContentBody: string;
|
||||
bodyTextLength: number;
|
||||
nonEmptyLineCount: number;
|
||||
bodyFingerprint: string;
|
||||
};
|
||||
|
||||
type WikiImportTaskContext = {
|
||||
requesterSessionKey?: string;
|
||||
ownerKey?: string;
|
||||
requesterOrigin?: Parameters<typeof createPluginTaskRun>[0]["requesterOrigin"];
|
||||
parentFlowId?: string;
|
||||
parentTaskId?: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
export type WikiImportResult = {
|
||||
inputPath: string;
|
||||
profileId: WikiImportProfileId;
|
||||
profileResolution: "automatic" | "explicit";
|
||||
artifactCount: number;
|
||||
importedCount: number;
|
||||
updatedCount: number;
|
||||
skippedCount: number;
|
||||
removedCount: number;
|
||||
pagePaths: string[];
|
||||
reportPath: string;
|
||||
indexesRefreshed: boolean;
|
||||
indexUpdatedFiles: string[];
|
||||
indexRefreshReason: "compiled" | "auto-compile-disabled";
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
};
|
||||
|
||||
type WikiImportProfileResolution = {
|
||||
profileId: WikiImportProfileId;
|
||||
profileResolution: "automatic" | "explicit";
|
||||
};
|
||||
|
||||
type ImportWriteResult = {
|
||||
pagePath: string;
|
||||
changed: boolean;
|
||||
created: boolean;
|
||||
reviewEntry: ImportReviewEntry;
|
||||
};
|
||||
|
||||
function normalizeImportProfileId(value?: string): WikiImportProfileId | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return (WIKI_IMPORT_PROFILE_IDS as readonly string[]).includes(normalized)
|
||||
? (normalized as WikiImportProfileId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function detectFenceLanguage(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === ".json" || ext === ".jsonl") {
|
||||
return "json";
|
||||
}
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
return "yaml";
|
||||
}
|
||||
if (ext === ".txt") {
|
||||
return "text";
|
||||
}
|
||||
return "markdown";
|
||||
}
|
||||
|
||||
function assertUtf8Text(buffer: Buffer, sourcePath: string): string {
|
||||
const preview = buffer.subarray(0, Math.min(buffer.length, 4096));
|
||||
if (preview.includes(0)) {
|
||||
throw new Error(`Cannot import binary file as text source: ${sourcePath}`);
|
||||
}
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
|
||||
function humanizeImportPath(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\//g, " / ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveImportArtifactTitle(params: {
|
||||
relativePath: string;
|
||||
raw: string;
|
||||
profileId: WikiImportArtifact["profileId"];
|
||||
titleOverride?: string;
|
||||
}): string {
|
||||
if (params.titleOverride?.trim()) {
|
||||
return params.titleOverride.trim();
|
||||
}
|
||||
if (params.profileId === "local-file") {
|
||||
return (
|
||||
extractTitleFromMarkdown(params.raw) ?? humanizeImportPath(path.basename(params.relativePath))
|
||||
);
|
||||
}
|
||||
return extractTitleFromMarkdown(params.raw) ?? humanizeImportPath(params.relativePath);
|
||||
}
|
||||
|
||||
function normalizeImportedAliases(frontmatter: Record<string, unknown>): string[] {
|
||||
const aliases = normalizeSingleOrTrimmedStringList(frontmatter.aliases);
|
||||
if (aliases.length > 0) {
|
||||
return aliases;
|
||||
}
|
||||
return normalizeSingleOrTrimmedStringList(frontmatter.alias);
|
||||
}
|
||||
|
||||
function renderMarkdownVaultBodyForEvidence(body: string): string {
|
||||
const withoutWikilinks = body.replace(
|
||||
IMPORTED_OBSIDIAN_LINK_PATTERN,
|
||||
(_match: string, rawTarget: string, rawLabel?: string) => {
|
||||
const target = rawTarget.trim();
|
||||
const label = rawLabel?.trim();
|
||||
return label || target;
|
||||
},
|
||||
);
|
||||
return withoutWikilinks.replace(
|
||||
IMPORTED_MARKDOWN_LINK_PATTERN,
|
||||
(match: string, label: string, rawTarget: string) => {
|
||||
const target = rawTarget.trim();
|
||||
if (!target || target.startsWith("#") || /^[a-z]+:/i.test(target)) {
|
||||
return match;
|
||||
}
|
||||
const normalizedTarget = target.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim();
|
||||
return normalizedTarget ? `${label} (${normalizedTarget})` : label;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildImportBodyMetrics(text: string): {
|
||||
bodyTextLength: number;
|
||||
nonEmptyLineCount: number;
|
||||
} {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
bodyTextLength: lines.join(" ").length,
|
||||
nonEmptyLineCount: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportBodyFingerprint(text: string): string {
|
||||
const normalized = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function prepareImportArtifact(params: {
|
||||
artifact: WikiImportArtifact;
|
||||
raw: string;
|
||||
titleOverride?: string;
|
||||
}): PreparedImportArtifact {
|
||||
const title = resolveImportArtifactTitle({
|
||||
relativePath: params.artifact.relativePath,
|
||||
raw: params.raw,
|
||||
profileId: params.artifact.profileId,
|
||||
titleOverride: params.artifact.profileId === "local-file" ? params.titleOverride : undefined,
|
||||
});
|
||||
if (params.artifact.profileId !== "markdown-vault") {
|
||||
const metrics = buildImportBodyMetrics(params.raw);
|
||||
return {
|
||||
title,
|
||||
importedTags: [],
|
||||
importedAliases: [],
|
||||
importedLinkTargets: [],
|
||||
renderedContentBody: renderMarkdownFence(
|
||||
params.raw,
|
||||
detectFenceLanguage(params.artifact.absolutePath),
|
||||
),
|
||||
bodyFingerprint: buildImportBodyFingerprint(params.raw),
|
||||
...metrics,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseWikiMarkdown(params.raw);
|
||||
const importedTags = normalizeSingleOrTrimmedStringList(parsed.frontmatter.tags);
|
||||
const importedAliases = normalizeImportedAliases(parsed.frontmatter);
|
||||
const importedLinkTargets = extractWikiLinks(parsed.body);
|
||||
const renderedContentBody = renderMarkdownVaultBodyForEvidence(parsed.body).trim();
|
||||
const metrics = buildImportBodyMetrics(renderedContentBody);
|
||||
|
||||
return {
|
||||
title,
|
||||
importedTags,
|
||||
importedAliases,
|
||||
importedLinkTargets,
|
||||
renderedContentBody:
|
||||
renderedContentBody.length > 0
|
||||
? renderedContentBody
|
||||
: "_Imported markdown note body was empty._",
|
||||
bodyFingerprint: buildImportBodyFingerprint(renderedContentBody),
|
||||
...metrics,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSkipMarkdownVaultDir(relativePath: string): boolean {
|
||||
const normalized = relativePath.replace(/\\/g, "/").replace(/\/+$/g, "");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized === ".obsidian" ||
|
||||
normalized.startsWith(".obsidian/") ||
|
||||
normalized === "logseq" ||
|
||||
normalized.startsWith("logseq/") ||
|
||||
normalized === ".git" ||
|
||||
normalized.startsWith(".git/") ||
|
||||
normalized === "node_modules" ||
|
||||
normalized.startsWith("node_modules/") ||
|
||||
normalized === ".trash" ||
|
||||
normalized.startsWith(".trash/") ||
|
||||
normalized === ".logseq" ||
|
||||
normalized.startsWith(".logseq/")
|
||||
);
|
||||
}
|
||||
|
||||
async function listImportFilesRecursive(params: {
|
||||
rootDir: string;
|
||||
allowedExtensions: ReadonlySet<string>;
|
||||
skipDir?: (relativePath: string) => boolean;
|
||||
}): Promise<string[]> {
|
||||
async function walk(relativeDir: string): Promise<string[]> {
|
||||
const fullDir = relativeDir ? path.join(params.rootDir, relativeDir) : params.rootDir;
|
||||
const entries = await fs.readdir(fullDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (params.skipDir?.(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
files.push(...(await walk(relativePath)));
|
||||
continue;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (entry.isFile() && params.allowedExtensions.has(ext)) {
|
||||
files.push(relativePath.replace(/\\/g, "/"));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
return (await walk("")).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function isMarkdownVaultRoot(inputPath: string): Promise<boolean> {
|
||||
for (const marker of MARKDOWN_VAULT_MARKERS) {
|
||||
if (await pathExists(path.join(inputPath, marker))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveWikiImportProfile(params: {
|
||||
inputPath: string;
|
||||
profileId?: WikiImportProfileId;
|
||||
}): Promise<WikiImportProfileResolution> {
|
||||
if (params.profileId) {
|
||||
return {
|
||||
profileId: params.profileId,
|
||||
profileResolution: "explicit",
|
||||
};
|
||||
}
|
||||
|
||||
const stat = await fs.stat(params.inputPath).catch(() => null);
|
||||
if (!stat) {
|
||||
throw new Error(`Import path not found: ${params.inputPath}`);
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
const ext = path.extname(params.inputPath).toLowerCase();
|
||||
if (!DIRECTORY_TEXT_EXTENSIONS.has(ext)) {
|
||||
throw new Error(`Import path is not a supported text source: ${params.inputPath}`);
|
||||
}
|
||||
if (await detectChatGptExportFile(params.inputPath)) {
|
||||
return {
|
||||
profileId: "chatgpt-export",
|
||||
profileResolution: "automatic",
|
||||
};
|
||||
}
|
||||
return {
|
||||
profileId: "local-file",
|
||||
profileResolution: "automatic",
|
||||
};
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Import path must be a file or directory: ${params.inputPath}`);
|
||||
}
|
||||
if (await isMarkdownVaultRoot(params.inputPath)) {
|
||||
return {
|
||||
profileId: "markdown-vault",
|
||||
profileResolution: "automatic",
|
||||
};
|
||||
}
|
||||
return {
|
||||
profileId: "directory-text",
|
||||
profileResolution: "automatic",
|
||||
};
|
||||
}
|
||||
|
||||
async function enumerateImportArtifacts(params: {
|
||||
inputPath: string;
|
||||
profileId: Exclude<WikiImportProfileId, "chatgpt-export">;
|
||||
}): Promise<WikiImportArtifact[]> {
|
||||
if (params.profileId === "local-file") {
|
||||
return [
|
||||
{
|
||||
absolutePath: path.resolve(params.inputPath),
|
||||
relativePath: path.basename(params.inputPath),
|
||||
profileId: "local-file",
|
||||
importRootPath: path.dirname(path.resolve(params.inputPath)),
|
||||
sourceType: "local-file",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const inputRoot = path.resolve(params.inputPath);
|
||||
const relativePaths = await listImportFilesRecursive({
|
||||
rootDir: inputRoot,
|
||||
allowedExtensions:
|
||||
params.profileId === "markdown-vault" ? MARKDOWN_VAULT_EXTENSIONS : DIRECTORY_TEXT_EXTENSIONS,
|
||||
...(params.profileId === "markdown-vault" ? { skipDir: shouldSkipMarkdownVaultDir } : {}),
|
||||
});
|
||||
|
||||
return relativePaths.map((relativePath) => ({
|
||||
absolutePath: path.join(inputRoot, relativePath),
|
||||
relativePath,
|
||||
profileId: params.profileId,
|
||||
importRootPath: inputRoot,
|
||||
sourceType: params.profileId === "markdown-vault" ? "markdown-vault" : "directory-text",
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveImportScopeKey(params: {
|
||||
inputPath: string;
|
||||
profileId: WikiImportProfileId;
|
||||
}): string {
|
||||
return `${params.profileId}:${path.resolve(params.inputPath)}`;
|
||||
}
|
||||
|
||||
function resolveImportPageIdentity(artifact: WikiImportArtifact): {
|
||||
pageId: string;
|
||||
pagePath: string;
|
||||
} {
|
||||
const rootHash = createHash("sha1").update(artifact.importRootPath).digest("hex").slice(0, 8);
|
||||
const relativeHash = createHash("sha1").update(artifact.relativePath).digest("hex").slice(0, 8);
|
||||
const artifactSlug = slugifyWikiSegment(
|
||||
artifact.relativePath.replace(/\.[^.]+$/, "").replace(/[\\/]+/g, "-"),
|
||||
);
|
||||
return {
|
||||
pageId: `source.import.${artifact.profileId}.${rootHash}.${relativeHash}`,
|
||||
pagePath: path
|
||||
.join(
|
||||
"sources",
|
||||
`import-${artifact.profileId}-${rootHash}-${artifactSlug}-${relativeHash}.md`,
|
||||
)
|
||||
.replace(/\\/g, "/"),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeImportReviewReport(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
inputPath: string;
|
||||
profileId: WikiImportProfileId;
|
||||
profileResolution: "automatic" | "explicit";
|
||||
artifactCount: number;
|
||||
importedCount: number;
|
||||
updatedCount: number;
|
||||
skippedCount: number;
|
||||
removedCount: number;
|
||||
pagePaths: string[];
|
||||
reviewEntries: ImportReviewEntry[];
|
||||
}): Promise<string> {
|
||||
const reportPath = path.join(params.config.vault.path, IMPORT_REVIEW_PATH);
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
reportPath,
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "report",
|
||||
id: "report.import-review",
|
||||
title: "Import Review",
|
||||
status: "active",
|
||||
sourceType: "wiki-import-report",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
body: buildImportReviewBody(params),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return IMPORT_REVIEW_PATH;
|
||||
}
|
||||
|
||||
async function writeImportArtifactPage(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
artifact: WikiImportArtifact;
|
||||
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
|
||||
scopeKey: string;
|
||||
titleOverride?: string;
|
||||
}): Promise<ImportWriteResult> {
|
||||
const stats = await fs.stat(params.artifact.absolutePath);
|
||||
const raw = assertUtf8Text(
|
||||
await fs.readFile(params.artifact.absolutePath),
|
||||
params.artifact.absolutePath,
|
||||
);
|
||||
const prepared = prepareImportArtifact({
|
||||
artifact: params.artifact,
|
||||
raw,
|
||||
titleOverride: params.titleOverride,
|
||||
});
|
||||
const { pageId, pagePath } = resolveImportPageIdentity(params.artifact);
|
||||
const renderFingerprint = createHash("sha1")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
profileId: params.artifact.profileId,
|
||||
sourceType: params.artifact.sourceType,
|
||||
importRootPath: params.artifact.importRootPath,
|
||||
relativePath: params.artifact.relativePath,
|
||||
title: prepared.title,
|
||||
importedTags: prepared.importedTags,
|
||||
importedAliases: prepared.importedAliases,
|
||||
importedLinkTargets: prepared.importedLinkTargets,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
|
||||
const writeResult = await writeImportedSourcePage({
|
||||
vaultRoot: params.config.vault.path,
|
||||
syncKey: await resolveArtifactKey(params.artifact.absolutePath),
|
||||
sourcePath: params.artifact.absolutePath,
|
||||
sourceUpdatedAtMs: stats.mtimeMs,
|
||||
sourceSize: stats.size,
|
||||
renderFingerprint,
|
||||
pagePath,
|
||||
group: "import",
|
||||
scopeKey: params.scopeKey,
|
||||
state: params.state,
|
||||
buildRendered: (_existingRaw, updatedAt) =>
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: pageId,
|
||||
title: prepared.title,
|
||||
sourceType: params.artifact.sourceType,
|
||||
sourcePath: params.artifact.absolutePath,
|
||||
importProfile: params.artifact.profileId,
|
||||
importRootPath: params.artifact.importRootPath,
|
||||
importRelativePath: params.artifact.relativePath,
|
||||
...(prepared.importedTags.length > 0 ? { importedTags: prepared.importedTags } : {}),
|
||||
...(prepared.importedAliases.length > 0
|
||||
? { importedAliases: prepared.importedAliases }
|
||||
: {}),
|
||||
...(prepared.importedLinkTargets.length > 0
|
||||
? { importedLinkTargets: prepared.importedLinkTargets }
|
||||
: {}),
|
||||
status: "active",
|
||||
updatedAt,
|
||||
},
|
||||
body: [
|
||||
`# ${prepared.title}`,
|
||||
"",
|
||||
"## Imported Source",
|
||||
`- Profile: \`${params.artifact.profileId}\``,
|
||||
`- Root: \`${params.artifact.importRootPath}\``,
|
||||
`- Relative path: \`${params.artifact.relativePath}\``,
|
||||
`- Updated: ${updatedAt}`,
|
||||
...(prepared.importedTags.length > 0
|
||||
? [`- Imported tags: ${prepared.importedTags.map((tag) => `\`${tag}\``).join(", ")}`]
|
||||
: []),
|
||||
...(prepared.importedAliases.length > 0
|
||||
? [
|
||||
`- Imported aliases: ${prepared.importedAliases
|
||||
.map((alias) => `\`${alias}\``)
|
||||
.join(", ")}`,
|
||||
]
|
||||
: []),
|
||||
...(prepared.importedLinkTargets.length > 0
|
||||
? [
|
||||
`- Imported links: ${prepared.importedLinkTargets
|
||||
.map((target) => `\`${target}\``)
|
||||
.join(", ")}`,
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
params.artifact.profileId === "markdown-vault" ? "## Imported Markdown" : "## Content",
|
||||
prepared.renderedContentBody,
|
||||
"",
|
||||
"## Notes",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].join("\n"),
|
||||
}),
|
||||
});
|
||||
return {
|
||||
...writeResult,
|
||||
reviewEntry: {
|
||||
title: prepared.title,
|
||||
relativePath: params.artifact.relativePath,
|
||||
pagePath,
|
||||
importedAliases: [...prepared.importedAliases],
|
||||
importedTags: [...prepared.importedTags],
|
||||
bodyTextLength: prepared.bodyTextLength,
|
||||
nonEmptyLineCount: prepared.nonEmptyLineCount,
|
||||
bodyFingerprint: prepared.bodyFingerprint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeChatGptConversationPage(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
exportPath: string;
|
||||
conversation: ChatGptExportConversation;
|
||||
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
|
||||
scopeKey: string;
|
||||
}): Promise<ImportWriteResult> {
|
||||
const stats = await fs.stat(params.exportPath);
|
||||
const exportHash = createHash("sha1").update(params.exportPath).digest("hex").slice(0, 8);
|
||||
const conversationHash = createHash("sha1")
|
||||
.update(params.conversation.conversationId)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
const conversationSlug = slugifyWikiSegment(params.conversation.title);
|
||||
const pageId = `source.import.chatgpt-export.${exportHash}.${conversationHash}`;
|
||||
const pagePath = path
|
||||
.join(
|
||||
"sources",
|
||||
`import-chatgpt-export-${exportHash}-${conversationSlug}-${conversationHash}.md`,
|
||||
)
|
||||
.replace(/\\/g, "/");
|
||||
const renderFingerprint = createHash("sha1")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
profileId: "chatgpt-export",
|
||||
exportPath: params.exportPath,
|
||||
conversationId: params.conversation.conversationId,
|
||||
title: params.conversation.title,
|
||||
relativePath: params.conversation.relativePath,
|
||||
transcriptBody: params.conversation.transcriptBody,
|
||||
messageCount: params.conversation.messageCount,
|
||||
participantRoles: params.conversation.participantRoles,
|
||||
conversationCreatedAt: params.conversation.conversationCreatedAt,
|
||||
conversationUpdatedAt: params.conversation.conversationUpdatedAt,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
const rendered = renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: pageId,
|
||||
title: params.conversation.title,
|
||||
sourceType: "chatgpt-export",
|
||||
sourcePath: params.exportPath,
|
||||
importProfile: "chatgpt-export",
|
||||
importRootPath: path.dirname(params.exportPath),
|
||||
importRelativePath: params.conversation.relativePath,
|
||||
importedTags: ["chatgpt-export"],
|
||||
importedAliases: [params.conversation.conversationId],
|
||||
status: "active",
|
||||
updatedAt: new Date(stats.mtimeMs).toISOString(),
|
||||
},
|
||||
body: [
|
||||
`# ${params.conversation.title}`,
|
||||
"",
|
||||
"## Imported Source",
|
||||
"- Profile: `chatgpt-export`",
|
||||
`- Export file: \`${params.exportPath}\``,
|
||||
`- Relative path: \`${params.conversation.relativePath}\``,
|
||||
`- Conversation id: \`${params.conversation.conversationId}\``,
|
||||
...(params.conversation.conversationCreatedAt
|
||||
? [`- Conversation created: ${params.conversation.conversationCreatedAt}`]
|
||||
: []),
|
||||
...(params.conversation.conversationUpdatedAt
|
||||
? [`- Conversation updated: ${params.conversation.conversationUpdatedAt}`]
|
||||
: []),
|
||||
`- Messages: ${params.conversation.messageCount}`,
|
||||
...(params.conversation.participantRoles.length > 0
|
||||
? [
|
||||
`- Participants: ${params.conversation.participantRoles
|
||||
.map((role) => `\`${role}\``)
|
||||
.join(", ")}`,
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
"## Conversation Transcript",
|
||||
params.conversation.transcriptBody,
|
||||
"",
|
||||
"## Notes",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
const writeResult = await writeImportedSourcePage({
|
||||
vaultRoot: params.config.vault.path,
|
||||
syncKey: `chatgpt-export:${params.exportPath}#${params.conversation.conversationId}`,
|
||||
sourcePath: params.exportPath,
|
||||
sourceUpdatedAtMs: stats.mtimeMs,
|
||||
sourceSize: stats.size,
|
||||
renderFingerprint,
|
||||
pagePath,
|
||||
group: "import",
|
||||
scopeKey: params.scopeKey,
|
||||
state: params.state,
|
||||
rendered,
|
||||
});
|
||||
const bodyMetrics = buildImportBodyMetrics(params.conversation.transcriptBody);
|
||||
return {
|
||||
...writeResult,
|
||||
reviewEntry: {
|
||||
title: params.conversation.title,
|
||||
relativePath: params.conversation.relativePath,
|
||||
pagePath,
|
||||
importedAliases: [params.conversation.conversationId],
|
||||
importedTags: ["chatgpt-export"],
|
||||
bodyFingerprint: buildImportBodyFingerprint(params.conversation.transcriptBody),
|
||||
...bodyMetrics,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function importMemoryWikiInput(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
inputPath: string;
|
||||
profileId?: string;
|
||||
title?: string;
|
||||
taskContext?: WikiImportTaskContext;
|
||||
}): Promise<WikiImportResult> {
|
||||
await initializeMemoryWikiVault(params.config);
|
||||
|
||||
const normalizedInputPath = path.resolve(params.inputPath);
|
||||
const requestedProfileId = normalizeImportProfileId(params.profileId);
|
||||
if (params.profileId && !requestedProfileId) {
|
||||
throw new Error(
|
||||
`Unknown import profile: ${params.profileId}. Expected one of: ${WIKI_IMPORT_PROFILE_IDS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const taskHandle = createPluginTaskRun({
|
||||
taskKind: IMPORT_TASK_KIND,
|
||||
sourceId: "memory-wiki:import",
|
||||
requesterSessionKey: params.taskContext?.requesterSessionKey,
|
||||
ownerKey:
|
||||
params.taskContext?.ownerKey ??
|
||||
(params.taskContext?.requesterSessionKey ? undefined : IMPORT_OWNER_KEY),
|
||||
requesterOrigin: params.taskContext?.requesterOrigin,
|
||||
parentFlowId: params.taskContext?.parentFlowId,
|
||||
parentTaskId: params.taskContext?.parentTaskId,
|
||||
agentId: params.taskContext?.agentId,
|
||||
label: "Wiki import",
|
||||
task: `Import wiki sources from ${normalizedInputPath}`,
|
||||
progressSummary: "Detecting import profile",
|
||||
});
|
||||
|
||||
try {
|
||||
const profile = await resolveWikiImportProfile({
|
||||
inputPath: normalizedInputPath,
|
||||
profileId: requestedProfileId,
|
||||
});
|
||||
recordPluginTaskProgress({
|
||||
handle: taskHandle,
|
||||
progressSummary: `Enumerating ${profile.profileId} sources`,
|
||||
eventSummary: `Detected ${profile.profileId} import profile`,
|
||||
});
|
||||
|
||||
const scopeKey = resolveImportScopeKey({
|
||||
inputPath: normalizedInputPath,
|
||||
profileId: profile.profileId,
|
||||
});
|
||||
const state = await readMemoryWikiSourceSyncState(params.config.vault.path);
|
||||
const activeKeys = new Set<string>();
|
||||
const results: ImportWriteResult[] = [];
|
||||
let artifactCount = 0;
|
||||
|
||||
if (profile.profileId === "chatgpt-export") {
|
||||
recordPluginTaskProgress({
|
||||
handle: taskHandle,
|
||||
progressSummary: "Reading chatgpt-export conversations",
|
||||
});
|
||||
const conversations = await parseChatGptExportFile(normalizedInputPath);
|
||||
artifactCount = conversations.length;
|
||||
for (const [index, conversation] of conversations.entries()) {
|
||||
activeKeys.add(`chatgpt-export:${normalizedInputPath}#${conversation.conversationId}`);
|
||||
recordPluginTaskProgress({
|
||||
handle: taskHandle,
|
||||
progressSummary: `Importing ${index + 1}/${conversations.length} conversations`,
|
||||
eventSummary: conversation.title,
|
||||
});
|
||||
results.push(
|
||||
await writeChatGptConversationPage({
|
||||
config: params.config,
|
||||
exportPath: normalizedInputPath,
|
||||
conversation,
|
||||
state,
|
||||
scopeKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const artifacts = await enumerateImportArtifacts({
|
||||
inputPath: normalizedInputPath,
|
||||
profileId: profile.profileId,
|
||||
});
|
||||
if (artifacts.length === 0) {
|
||||
throw new Error(
|
||||
`No importable sources found for ${profile.profileId}: ${normalizedInputPath}`,
|
||||
);
|
||||
}
|
||||
artifactCount = artifacts.length;
|
||||
for (const [index, artifact] of artifacts.entries()) {
|
||||
activeKeys.add(await resolveArtifactKey(artifact.absolutePath));
|
||||
recordPluginTaskProgress({
|
||||
handle: taskHandle,
|
||||
progressSummary: `Importing ${index + 1}/${artifacts.length} sources`,
|
||||
eventSummary: artifact.relativePath,
|
||||
});
|
||||
results.push(
|
||||
await writeImportArtifactPage({
|
||||
config: params.config,
|
||||
artifact,
|
||||
state,
|
||||
scopeKey,
|
||||
titleOverride: params.title,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const removedCount = await pruneImportedSourceEntries({
|
||||
vaultRoot: params.config.vault.path,
|
||||
group: "import",
|
||||
activeKeys,
|
||||
state,
|
||||
scopeKey,
|
||||
});
|
||||
await writeMemoryWikiSourceSyncState(params.config.vault.path, state);
|
||||
|
||||
const pagePaths = results
|
||||
.map((result) => result.pagePath)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
const importedCount = results.filter((result) => result.changed && result.created).length;
|
||||
const updatedCount = results.filter((result) => result.changed && !result.created).length;
|
||||
const skippedCount = results.filter((result) => !result.changed).length;
|
||||
|
||||
recordPluginTaskProgress({
|
||||
handle: taskHandle,
|
||||
progressSummary: "Writing import review",
|
||||
});
|
||||
const reportPath = await writeImportReviewReport({
|
||||
config: params.config,
|
||||
inputPath: normalizedInputPath,
|
||||
profileId: profile.profileId,
|
||||
profileResolution: profile.profileResolution,
|
||||
artifactCount,
|
||||
importedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
removedCount,
|
||||
pagePaths,
|
||||
reviewEntries: results.map((result) => result.reviewEntry),
|
||||
});
|
||||
|
||||
let compile: CompileMemoryWikiResult | null = null;
|
||||
let indexRefreshReason: WikiImportResult["indexRefreshReason"] = "auto-compile-disabled";
|
||||
if (params.config.ingest.autoCompile) {
|
||||
recordPluginTaskProgress({
|
||||
handle: taskHandle,
|
||||
progressSummary: "Compiling wiki indexes",
|
||||
});
|
||||
compile = await compileMemoryWikiVault(params.config);
|
||||
indexRefreshReason = "compiled";
|
||||
}
|
||||
|
||||
await appendMemoryWikiLog(params.config.vault.path, {
|
||||
type: "ingest",
|
||||
timestamp: new Date().toISOString(),
|
||||
details: {
|
||||
sourceType: "memory-import",
|
||||
inputPath: normalizedInputPath,
|
||||
profileId: profile.profileId,
|
||||
profileResolution: profile.profileResolution,
|
||||
artifactCount,
|
||||
importedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
removedCount,
|
||||
reportPath,
|
||||
},
|
||||
});
|
||||
|
||||
const result: WikiImportResult = {
|
||||
inputPath: normalizedInputPath,
|
||||
profileId: profile.profileId,
|
||||
profileResolution: profile.profileResolution,
|
||||
artifactCount,
|
||||
importedCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
removedCount,
|
||||
pagePaths,
|
||||
reportPath,
|
||||
indexesRefreshed: compile !== null,
|
||||
indexUpdatedFiles: compile?.updatedFiles ?? [],
|
||||
indexRefreshReason,
|
||||
taskId: taskHandle.taskId,
|
||||
runId: taskHandle.runId,
|
||||
};
|
||||
|
||||
completePluginTaskRun({
|
||||
handle: taskHandle,
|
||||
progressSummary: `Imported ${artifactCount} sources`,
|
||||
terminalSummary: `Imported ${artifactCount} sources via ${profile.profileId} (${importedCount} new, ${updatedCount} updated, ${skippedCount} unchanged, ${removedCount} removed).`,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
failPluginTaskRun({
|
||||
handle: taskHandle,
|
||||
error,
|
||||
progressSummary: "Wiki import failed",
|
||||
terminalSummary: `Wiki import failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,10 @@ export type WikiPageSummary = {
|
||||
claims: WikiClaim[];
|
||||
contradictions: string[];
|
||||
questions: string[];
|
||||
importedTags: string[];
|
||||
importedAliases: string[];
|
||||
importedLinkTargets: string[];
|
||||
confidence?: number;
|
||||
sourceType?: string;
|
||||
provenanceMode?: string;
|
||||
sourcePath?: string;
|
||||
importRelativePath?: string;
|
||||
bridgeRelativePath?: string;
|
||||
bridgeWorkspaceDir?: string;
|
||||
unsafeLocalConfiguredPath?: string;
|
||||
@@ -265,9 +261,6 @@ export function toWikiPageSummary(params: {
|
||||
claims: normalizeWikiClaims(parsed.frontmatter.claims),
|
||||
contradictions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.contradictions),
|
||||
questions: normalizeSingleOrTrimmedStringList(parsed.frontmatter.questions),
|
||||
importedTags: normalizeSingleOrTrimmedStringList(parsed.frontmatter.importedTags),
|
||||
importedAliases: normalizeSingleOrTrimmedStringList(parsed.frontmatter.importedAliases),
|
||||
importedLinkTargets: normalizeSingleOrTrimmedStringList(parsed.frontmatter.importedLinkTargets),
|
||||
confidence:
|
||||
typeof parsed.frontmatter.confidence === "number" &&
|
||||
Number.isFinite(parsed.frontmatter.confidence)
|
||||
@@ -276,7 +269,6 @@ export function toWikiPageSummary(params: {
|
||||
sourceType: normalizeOptionalString(parsed.frontmatter.sourceType),
|
||||
provenanceMode: normalizeOptionalString(parsed.frontmatter.provenanceMode),
|
||||
sourcePath: normalizeOptionalString(parsed.frontmatter.sourcePath),
|
||||
importRelativePath: normalizeOptionalString(parsed.frontmatter.importRelativePath),
|
||||
bridgeRelativePath: normalizeOptionalString(parsed.frontmatter.bridgeRelativePath),
|
||||
bridgeWorkspaceDir: normalizeOptionalString(parsed.frontmatter.bridgeWorkspaceDir),
|
||||
unsafeLocalConfiguredPath: normalizeOptionalString(
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveQueryableWikiPageByLookup } from "./query-lookup.js";
|
||||
|
||||
describe("resolveQueryableWikiPageByLookup", () => {
|
||||
const pages = [
|
||||
{
|
||||
relativePath: "sources/alpha-import.md",
|
||||
title: "Alpha Imported Note",
|
||||
id: "source.import.alpha",
|
||||
importedAliases: ["Alpha Canon"],
|
||||
importRelativePath: "projects/alpha.md",
|
||||
},
|
||||
{
|
||||
relativePath: "entities/beta.md",
|
||||
title: "Beta",
|
||||
id: "entity.beta",
|
||||
importedAliases: [],
|
||||
importRelativePath: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it("resolves pages by title", () => {
|
||||
expect(resolveQueryableWikiPageByLookup(pages, "Alpha Imported Note")).toMatchObject({
|
||||
relativePath: "sources/alpha-import.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves pages by imported alias case-insensitively", () => {
|
||||
expect(resolveQueryableWikiPageByLookup(pages, "alpha canon")).toMatchObject({
|
||||
relativePath: "sources/alpha-import.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves pages by imported vault-relative path", () => {
|
||||
expect(resolveQueryableWikiPageByLookup(pages, "projects/alpha")).toMatchObject({
|
||||
relativePath: "sources/alpha-import.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import path from "node:path";
|
||||
import type { WikiPageSummary } from "./markdown.js";
|
||||
|
||||
export type QueryableWikiLookupPage = Pick<
|
||||
WikiPageSummary,
|
||||
"relativePath" | "title" | "id" | "importedAliases" | "importRelativePath"
|
||||
>;
|
||||
|
||||
export function normalizeLookupKey(value: string): string {
|
||||
const normalized = value.trim().replace(/\\/g, "/");
|
||||
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeLookupLabel(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.md$/i, "")
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveQueryableWikiPageByLookup<T extends QueryableWikiLookupPage>(
|
||||
pages: T[],
|
||||
lookup: string,
|
||||
): T | null {
|
||||
const key = normalizeLookupKey(lookup);
|
||||
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
|
||||
const normalizedLabel = normalizeLookupLabel(lookup);
|
||||
return (
|
||||
pages.find((page) => page.relativePath === key) ??
|
||||
pages.find((page) => page.relativePath === withExtension) ??
|
||||
pages.find((page) => page.relativePath.replace(/\.md$/i, "") === key) ??
|
||||
pages.find((page) => path.basename(page.relativePath, ".md") === key) ??
|
||||
pages.find((page) => page.importRelativePath === key) ??
|
||||
pages.find((page) => page.importRelativePath === withExtension) ??
|
||||
pages.find((page) => page.importRelativePath?.replace(/\.md$/i, "") === key) ??
|
||||
pages.find((page) => path.basename(page.importRelativePath ?? "", ".md") === key) ??
|
||||
pages.find((page) => page.id === key) ??
|
||||
pages.find((page) => normalizeLookupLabel(page.title) === normalizedLabel) ??
|
||||
pages.find((page) =>
|
||||
page.importedAliases.some((alias) => normalizeLookupLabel(alias) === normalizedLabel),
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -153,49 +153,6 @@ describe("searchMemoryWiki", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("finds imported markdown-vault pages by imported aliases and tags", async () => {
|
||||
const { rootDir, config } = await createQueryVault({
|
||||
initialize: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "sources", "alpha-import.md"),
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.import.alpha",
|
||||
title: "Alpha Project Note",
|
||||
sourceType: "markdown-vault",
|
||||
importedTags: ["project-alpha"],
|
||||
importedAliases: ["Alpha Canon"],
|
||||
importedLinkTargets: ["beta-project"],
|
||||
},
|
||||
body: `# Alpha Project Note
|
||||
|
||||
## Imported Source
|
||||
- Imported tags: \`project-alpha\`
|
||||
- Imported aliases: \`Alpha Canon\`
|
||||
- Imported links: \`beta-project\`
|
||||
|
||||
## Imported Markdown
|
||||
Alpha project planning notes.
|
||||
`,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const aliasResults = await searchMemoryWiki({ config, query: "alpha canon" });
|
||||
expect(aliasResults[0]).toMatchObject({
|
||||
corpus: "wiki",
|
||||
path: "sources/alpha-import.md",
|
||||
});
|
||||
|
||||
const tagResults = await searchMemoryWiki({ config, query: "project-alpha" });
|
||||
expect(tagResults[0]).toMatchObject({
|
||||
corpus: "wiki",
|
||||
path: "sources/alpha-import.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("ranks fresh supported claims ahead of stale contested claims", async () => {
|
||||
const { rootDir, config } = await createQueryVault({
|
||||
initialize: true,
|
||||
@@ -572,46 +529,6 @@ describe("getMemoryWikiPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves wiki pages by title and imported alias", async () => {
|
||||
const { rootDir, config } = await createQueryVault({
|
||||
initialize: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(rootDir, "sources", "alpha-import.md"),
|
||||
renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.import.alpha",
|
||||
title: "Alpha Imported Note",
|
||||
sourceType: "markdown-vault",
|
||||
importedAliases: ["Alpha Canon"],
|
||||
},
|
||||
body: "# Alpha Imported Note\n\nimported alpha body\n",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const titleResult = await getMemoryWikiPage({
|
||||
config,
|
||||
lookup: "Alpha Imported Note",
|
||||
});
|
||||
expect(titleResult).toMatchObject({
|
||||
corpus: "wiki",
|
||||
path: "sources/alpha-import.md",
|
||||
title: "Alpha Imported Note",
|
||||
});
|
||||
|
||||
const aliasResult = await getMemoryWikiPage({
|
||||
config,
|
||||
lookup: "alpha canon",
|
||||
});
|
||||
expect(aliasResult).toMatchObject({
|
||||
corpus: "wiki",
|
||||
path: "sources/alpha-import.md",
|
||||
title: "Alpha Imported Note",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to active memory reads when memory corpus is selected", async () => {
|
||||
const { config } = await createQueryVault({
|
||||
initialize: true,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type WikiClaim,
|
||||
type WikiPageSummary,
|
||||
} from "./markdown.js";
|
||||
import { normalizeLookupKey, resolveQueryableWikiPageByLookup } from "./query-lookup.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
const QUERY_DIRS = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
|
||||
@@ -24,13 +23,9 @@ type QueryDigestPage = {
|
||||
title: string;
|
||||
kind: WikiPageSummary["kind"];
|
||||
path: string;
|
||||
importRelativePath?: string;
|
||||
sourceIds: string[];
|
||||
questions: string[];
|
||||
contradictions: string[];
|
||||
importedTags?: string[];
|
||||
importedAliases?: string[];
|
||||
importedLinkTargets?: string[];
|
||||
};
|
||||
|
||||
type QueryDigestClaim = {
|
||||
@@ -199,14 +194,10 @@ function buildPageSearchText(page: QueryableWikiPage): string {
|
||||
return [
|
||||
page.title,
|
||||
page.relativePath,
|
||||
page.importRelativePath ?? "",
|
||||
page.id ?? "",
|
||||
page.sourceIds.join(" "),
|
||||
page.questions.join(" "),
|
||||
page.contradictions.join(" "),
|
||||
page.importedTags.join(" "),
|
||||
page.importedAliases.join(" "),
|
||||
page.importedLinkTargets.join(" "),
|
||||
page.claims.map((claim) => claim.text).join(" "),
|
||||
page.claims.map((claim) => claim.id ?? "").join(" "),
|
||||
]
|
||||
@@ -218,14 +209,10 @@ function buildDigestPageSearchText(page: QueryDigestPage, claims: QueryDigestCla
|
||||
return [
|
||||
page.title,
|
||||
page.path,
|
||||
page.importRelativePath ?? "",
|
||||
page.id ?? "",
|
||||
page.sourceIds.join(" "),
|
||||
page.questions.join(" "),
|
||||
page.contradictions.join(" "),
|
||||
(page.importedTags ?? []).join(" "),
|
||||
(page.importedAliases ?? []).join(" "),
|
||||
(page.importedLinkTargets ?? []).join(" "),
|
||||
claims.map((claim) => claim.text).join(" "),
|
||||
claims.map((claim) => claim.id ?? "").join(" "),
|
||||
]
|
||||
@@ -285,7 +272,6 @@ function buildDigestCandidatePaths(params: {
|
||||
let score = 1;
|
||||
const titleLower = page.title.toLowerCase();
|
||||
const pathLower = page.path.toLowerCase();
|
||||
const importRelativePathLower = page.importRelativePath?.toLowerCase() ?? "";
|
||||
const idLower = page.id?.toLowerCase() ?? "";
|
||||
if (titleLower === queryLower) {
|
||||
score += 50;
|
||||
@@ -295,9 +281,6 @@ function buildDigestCandidatePaths(params: {
|
||||
if (pathLower.includes(queryLower)) {
|
||||
score += 10;
|
||||
}
|
||||
if (importRelativePathLower.includes(queryLower)) {
|
||||
score += 14;
|
||||
}
|
||||
if (idLower.includes(queryLower)) {
|
||||
score += 20;
|
||||
}
|
||||
@@ -391,7 +374,6 @@ function scorePage(page: QueryableWikiPage, query: string): number {
|
||||
const queryLower = query.toLowerCase();
|
||||
const titleLower = page.title.toLowerCase();
|
||||
const pathLower = page.relativePath.toLowerCase();
|
||||
const importRelativePathLower = page.importRelativePath?.toLowerCase() ?? "";
|
||||
const idLower = page.id?.toLowerCase() ?? "";
|
||||
const metadataLower = buildPageSearchText(page).toLowerCase();
|
||||
const rawLower = page.raw.toLowerCase();
|
||||
@@ -399,7 +381,6 @@ function scorePage(page: QueryableWikiPage, query: string): number {
|
||||
!(
|
||||
titleLower.includes(queryLower) ||
|
||||
pathLower.includes(queryLower) ||
|
||||
importRelativePathLower.includes(queryLower) ||
|
||||
idLower.includes(queryLower) ||
|
||||
metadataLower.includes(queryLower) ||
|
||||
rawLower.includes(queryLower)
|
||||
@@ -417,24 +398,12 @@ function scorePage(page: QueryableWikiPage, query: string): number {
|
||||
if (pathLower.includes(queryLower)) {
|
||||
score += 10;
|
||||
}
|
||||
if (importRelativePathLower.includes(queryLower)) {
|
||||
score += 14;
|
||||
}
|
||||
if (idLower.includes(queryLower)) {
|
||||
score += 20;
|
||||
}
|
||||
if (page.sourceIds.some((sourceId) => sourceId.toLowerCase().includes(queryLower))) {
|
||||
score += 12;
|
||||
}
|
||||
if (page.importedTags.some((tag) => tag.toLowerCase().includes(queryLower))) {
|
||||
score += 10;
|
||||
}
|
||||
if (page.importedAliases.some((alias) => alias.toLowerCase().includes(queryLower))) {
|
||||
score += 16;
|
||||
}
|
||||
if (page.importedLinkTargets.some((target) => target.toLowerCase().includes(queryLower))) {
|
||||
score += 8;
|
||||
}
|
||||
const matchingClaims = getMatchingClaims(page, queryLower);
|
||||
if (matchingClaims.length > 0) {
|
||||
score += rankClaimMatch(page, matchingClaims[0], queryLower);
|
||||
@@ -445,7 +414,10 @@ function scorePage(page: QueryableWikiPage, query: string): number {
|
||||
return score;
|
||||
}
|
||||
|
||||
export { resolveQueryableWikiPageByLookup } from "./query-lookup.js";
|
||||
function normalizeLookupKey(value: string): string {
|
||||
const normalized = value.trim().replace(/\\/g, "/");
|
||||
return normalized.endsWith(".md") ? normalized : normalized.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildLookupCandidates(lookup: string): string[] {
|
||||
const normalized = normalizeLookupKey(lookup);
|
||||
@@ -631,6 +603,22 @@ function resolveDigestClaimLookup(digest: QueryDigestBundle, lookup: string): st
|
||||
return match?.pagePath ?? null;
|
||||
}
|
||||
|
||||
export function resolveQueryableWikiPageByLookup(
|
||||
pages: QueryableWikiPage[],
|
||||
lookup: string,
|
||||
): QueryableWikiPage | null {
|
||||
const key = normalizeLookupKey(lookup);
|
||||
const withExtension = key.endsWith(".md") ? key : `${key}.md`;
|
||||
return (
|
||||
pages.find((page) => page.relativePath === key) ??
|
||||
pages.find((page) => page.relativePath === withExtension) ??
|
||||
pages.find((page) => page.relativePath.replace(/\.md$/i, "") === key) ??
|
||||
pages.find((page) => path.basename(page.relativePath, ".md") === key) ??
|
||||
pages.find((page) => page.id === key) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchMemoryWiki(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
appConfig?: OpenClawConfig;
|
||||
|
||||
@@ -9,29 +9,18 @@ import {
|
||||
|
||||
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
|
||||
|
||||
export async function writeImportedSourcePage(
|
||||
params: {
|
||||
vaultRoot: string;
|
||||
syncKey: string;
|
||||
sourcePath: string;
|
||||
sourceUpdatedAtMs: number;
|
||||
sourceSize: number;
|
||||
renderFingerprint: string;
|
||||
pagePath: string;
|
||||
group: MemoryWikiImportedSourceGroup;
|
||||
scopeKey?: string;
|
||||
state: ImportedSourceState;
|
||||
} & (
|
||||
| {
|
||||
buildRendered: (raw: string, updatedAt: string) => string;
|
||||
rendered?: never;
|
||||
}
|
||||
| {
|
||||
rendered: string;
|
||||
buildRendered?: never;
|
||||
}
|
||||
),
|
||||
): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
|
||||
export async function writeImportedSourcePage(params: {
|
||||
vaultRoot: string;
|
||||
syncKey: string;
|
||||
sourcePath: string;
|
||||
sourceUpdatedAtMs: number;
|
||||
sourceSize: number;
|
||||
renderFingerprint: string;
|
||||
pagePath: string;
|
||||
group: MemoryWikiImportedSourceGroup;
|
||||
state: ImportedSourceState;
|
||||
buildRendered: (raw: string, updatedAt: string) => string;
|
||||
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
|
||||
const pageAbsPath = path.join(params.vaultRoot, params.pagePath);
|
||||
const created = !(await pathExists(pageAbsPath));
|
||||
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
|
||||
@@ -49,10 +38,8 @@ export async function writeImportedSourcePage(
|
||||
return { pagePath: params.pagePath, changed: false, created };
|
||||
}
|
||||
|
||||
const rendered =
|
||||
"rendered" in params
|
||||
? params.rendered
|
||||
: params.buildRendered(await fs.readFile(params.sourcePath, "utf8"), updatedAt);
|
||||
const raw = await fs.readFile(params.sourcePath, "utf8");
|
||||
const rendered = params.buildRendered(raw, updatedAt);
|
||||
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
|
||||
if (existing !== rendered) {
|
||||
await fs.writeFile(pageAbsPath, rendered, "utf8");
|
||||
@@ -63,7 +50,6 @@ export async function writeImportedSourcePage(
|
||||
state: params.state,
|
||||
entry: {
|
||||
group: params.group,
|
||||
...(params.scopeKey ? { scopeKey: params.scopeKey } : {}),
|
||||
pagePath: params.pagePath,
|
||||
sourcePath: params.sourcePath,
|
||||
sourceUpdatedAtMs: params.sourceUpdatedAtMs,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local" | "import";
|
||||
export type MemoryWikiImportedSourceGroup = "bridge" | "unsafe-local";
|
||||
|
||||
export type MemoryWikiImportedSourceStateEntry = {
|
||||
group: MemoryWikiImportedSourceGroup;
|
||||
scopeKey?: string;
|
||||
pagePath: string;
|
||||
sourcePath: string;
|
||||
sourceUpdatedAtMs: number;
|
||||
@@ -101,15 +100,10 @@ export async function pruneImportedSourceEntries(params: {
|
||||
group: MemoryWikiImportedSourceGroup;
|
||||
activeKeys: Set<string>;
|
||||
state: MemoryWikiImportedSourceState;
|
||||
scopeKey?: string;
|
||||
}): Promise<number> {
|
||||
let removedCount = 0;
|
||||
for (const [syncKey, entry] of Object.entries(params.state.entries)) {
|
||||
if (
|
||||
entry.group !== params.group ||
|
||||
params.activeKeys.has(syncKey) ||
|
||||
(params.scopeKey !== undefined && entry.scopeKey !== params.scopeKey)
|
||||
) {
|
||||
if (entry.group !== params.group || params.activeKeys.has(syncKey)) {
|
||||
continue;
|
||||
}
|
||||
const pageAbsPath = path.join(params.vaultRoot, entry.pagePath);
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
"videoGenerationProviders": ["minimax"],
|
||||
"webSearchProviders": ["minimax"]
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "MiniMax Coding Plan key",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
GRAPH_ROOT,
|
||||
inferPlaceholder,
|
||||
isRecord,
|
||||
readNestedString,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
normalizeContentType,
|
||||
@@ -39,17 +38,6 @@ type GraphAttachment = {
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
return normalizeOptionalString(current);
|
||||
}
|
||||
|
||||
export function buildMSTeamsGraphMessageUrls(params: {
|
||||
conversationType?: string | null;
|
||||
conversationId?: string | null;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
normalizeHostnameSuffixAllowlist,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-policy";
|
||||
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { MSTeamsAttachmentLike } from "./types.js";
|
||||
|
||||
type InlineImageCandidate =
|
||||
@@ -78,6 +78,17 @@ export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
|
||||
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
export { isRecord };
|
||||
|
||||
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
return normalizeOptionalString(current);
|
||||
}
|
||||
|
||||
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { isRecord } from "./attachments/shared.js";
|
||||
import { isRecord, readNestedString } from "./attachments/shared.js";
|
||||
import { resolveMSTeamsStorePath } from "./storage.js";
|
||||
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
||||
|
||||
@@ -88,11 +87,6 @@ function readNestedValue(value: unknown, keys: Array<string | number>): unknown
|
||||
return current;
|
||||
}
|
||||
|
||||
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
const found = readNestedValue(value, keys);
|
||||
return normalizeOptionalString(found);
|
||||
}
|
||||
|
||||
export function extractMSTeamsPollVote(
|
||||
activity: { value?: unknown } | undefined,
|
||||
): MSTeamsPollVote | null {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
|
||||
|
||||
function isTruthyEnvValue(value?: string): boolean {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
const normalized = normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,14 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin {
|
||||
liveTest: {
|
||||
defaultModelRef: CODEX_CLI_DEFAULT_MODEL_REF,
|
||||
defaultImageProbe: true,
|
||||
defaultMcpProbe: true,
|
||||
docker: {
|
||||
npmPackage: "@openai/codex",
|
||||
binaryName: "codex",
|
||||
},
|
||||
},
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "codex-config-overrides",
|
||||
config: {
|
||||
command: "codex",
|
||||
args: [
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export function trimNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const trimNonEmptyString = normalizeOptionalString;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-entry";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
|
||||
export function normalizeConfig(params: { provider: string; providerConfig: ModelProviderConfig }) {
|
||||
return params.providerConfig;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
definePluginEntry,
|
||||
type OpenClawPluginApi,
|
||||
@@ -259,7 +260,7 @@ function formatHelp(): string {
|
||||
}
|
||||
|
||||
function parseGroup(raw: string | undefined): ArmGroup | null {
|
||||
const value = (raw ?? "").trim().toLowerCase();
|
||||
const value = normalizeOptionalString(raw)?.toLowerCase() ?? "";
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
1
extensions/qa-lab/model-selection.ts
Normal file
1
extensions/qa-lab/model-selection.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src/model-selection.js";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../src/model-selection.js";
|
||||
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import {
|
||||
type Bootstrap,
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
createChannelNativeOriginTargetResolver,
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listSlackAccountIds } from "./accounts.js";
|
||||
import { isSlackApprovalAuthorizedSender } from "./approval-auth.js";
|
||||
import {
|
||||
@@ -46,7 +49,7 @@ function normalizeSlackThreadMatchKey(threadId?: string): string {
|
||||
}
|
||||
|
||||
function resolveTurnSourceSlackOriginTarget(request: ApprovalRequest): SlackOriginTarget | null {
|
||||
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
|
||||
if (turnSourceChannel !== "slack" || !turnSourceTo) {
|
||||
return null;
|
||||
|
||||
@@ -67,13 +67,6 @@ function extractScopes(payload: unknown): string[] {
|
||||
return normalizeScopes(scopes);
|
||||
}
|
||||
|
||||
function readError(payload: unknown): string | undefined {
|
||||
if (!isRecord(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeOptionalString(payload.error);
|
||||
}
|
||||
|
||||
async function callSlack(
|
||||
client: WebClient,
|
||||
method: SlackScopesSource,
|
||||
@@ -103,7 +96,7 @@ export async function fetchSlackScopes(
|
||||
if (scopes.length > 0) {
|
||||
return { ok: true, scopes, source: method };
|
||||
}
|
||||
const error = readError(result);
|
||||
const error = isRecord(result) ? normalizeOptionalString(result.error) : undefined;
|
||||
if (error) {
|
||||
errors.push(`${method}: ${error}`);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
isTtsProviderConfigured,
|
||||
listSpeechVoices,
|
||||
maybeApplyTtsToPayload,
|
||||
resolveExplicitTtsOverrides,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
|
||||
@@ -25,8 +25,8 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
CONFIG_DIR,
|
||||
normalizeOptionalString,
|
||||
resolveConfigDir,
|
||||
resolveUserPath,
|
||||
stripMarkdown,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
summarizeText,
|
||||
type SpeechModelOverridePolicy,
|
||||
type SpeechProviderConfig,
|
||||
type SpeechProviderOverrides,
|
||||
type SpeechVoiceOption,
|
||||
type TtsDirectiveOverrides,
|
||||
type TtsDirectiveParseResult,
|
||||
@@ -173,7 +174,7 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
|
||||
if (envPath) {
|
||||
return resolveUserPath(envPath);
|
||||
}
|
||||
return path.join(CONFIG_DIR, "settings", "tts.json");
|
||||
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
|
||||
}
|
||||
|
||||
function resolveModelOverridePolicy(
|
||||
@@ -327,7 +328,9 @@ export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig {
|
||||
mode: raw.mode ?? "final",
|
||||
provider:
|
||||
normalizeConfiguredSpeechProviderId(raw.provider) ??
|
||||
(providerSource === "config" ? raw.provider?.trim().toLowerCase() || "" : ""),
|
||||
(providerSource === "config"
|
||||
? (normalizeOptionalString(raw.provider)?.toLowerCase() ?? "")
|
||||
: ""),
|
||||
providerSource,
|
||||
summaryModel: normalizeOptionalString(raw.summaryModel),
|
||||
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
|
||||
@@ -500,6 +503,66 @@ export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveExplicitTtsOverrides(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prefsPath?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
voiceId?: string;
|
||||
}): TtsDirectiveOverrides {
|
||||
const providerInput = params.provider?.trim();
|
||||
const modelId = params.modelId?.trim();
|
||||
const voiceId = params.voiceId?.trim();
|
||||
const config = resolveTtsConfig(params.cfg);
|
||||
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
|
||||
const selectedProvider =
|
||||
canonicalizeSpeechProviderId(providerInput, params.cfg) ??
|
||||
(modelId || voiceId ? getTtsProvider(config, prefsPath) : undefined);
|
||||
|
||||
if (providerInput && !selectedProvider) {
|
||||
throw new Error(`Unknown TTS provider "${providerInput}".`);
|
||||
}
|
||||
|
||||
if (!modelId && !voiceId) {
|
||||
return selectedProvider ? { provider: selectedProvider } : {};
|
||||
}
|
||||
|
||||
if (!selectedProvider) {
|
||||
throw new Error("TTS model or voice overrides require a resolved provider.");
|
||||
}
|
||||
|
||||
const provider = getSpeechProvider(selectedProvider, params.cfg);
|
||||
if (!provider) {
|
||||
throw new Error(`speech provider ${selectedProvider} is not registered`);
|
||||
}
|
||||
if (!provider.resolveTalkOverrides) {
|
||||
throw new Error(
|
||||
`TTS provider "${selectedProvider}" does not support model or voice overrides.`,
|
||||
);
|
||||
}
|
||||
|
||||
const providerOverrides = provider.resolveTalkOverrides({
|
||||
talkProviderConfig: {},
|
||||
params: {
|
||||
...(voiceId ? { voiceId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
},
|
||||
});
|
||||
if ((voiceId || modelId) && (!providerOverrides || Object.keys(providerOverrides).length === 0)) {
|
||||
throw new Error(
|
||||
`TTS provider "${selectedProvider}" ignored the requested model or voice overrides.`,
|
||||
);
|
||||
}
|
||||
|
||||
const overridesRecord = providerOverrides as SpeechProviderOverrides;
|
||||
return {
|
||||
provider: selectedProvider,
|
||||
providerOverrides: {
|
||||
[provider.id]: overridesRecord,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getTtsMaxLength(prefsPath: string): number {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
|
||||
|
||||
function mask(s: string, keep: number = 6): string {
|
||||
@@ -79,11 +80,15 @@ function findVoice(voices: SpeechVoiceOption[], query: string): SpeechVoiceOptio
|
||||
if (byId) {
|
||||
return byId;
|
||||
}
|
||||
const exactName = voices.find((v) => (v.name ?? "").trim().toLowerCase() === lower);
|
||||
const exactName = voices.find(
|
||||
(v) => (normalizeOptionalString(v.name)?.toLowerCase() ?? "") === lower,
|
||||
);
|
||||
if (exactName) {
|
||||
return exactName;
|
||||
}
|
||||
const partial = voices.find((v) => (v.name ?? "").trim().toLowerCase().includes(lower));
|
||||
const partial = voices.find((v) =>
|
||||
(normalizeOptionalString(v.name)?.toLowerCase() ?? "").includes(lower),
|
||||
);
|
||||
return partial ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listTelegramAccountIds } from "./accounts.js";
|
||||
import {
|
||||
getTelegramExecApprovalApprovers,
|
||||
@@ -27,7 +30,7 @@ type TelegramOriginTarget = { to: string; threadId?: number };
|
||||
function resolveTurnSourceTelegramOriginTarget(
|
||||
request: ApprovalRequest,
|
||||
): TelegramOriginTarget | null {
|
||||
const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
|
||||
const parsedTurnSourceTarget = rawTurnSourceTo ? parseTelegramTarget(rawTurnSourceTo) : null;
|
||||
const turnSourceTo = normalizeTelegramChatId(parsedTurnSourceTarget?.chatId ?? rawTurnSourceTo);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
|
||||
|
||||
export type TelegramCustomCommandInput = {
|
||||
@@ -17,7 +19,7 @@ export function normalizeTelegramCommandName(value: string): string {
|
||||
return "";
|
||||
}
|
||||
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
|
||||
return withoutSlash.trim().toLowerCase().replace(/-/g, "_");
|
||||
return (normalizeOptionalLowercaseString(withoutSlash) ?? "").replace(/-/g, "_");
|
||||
}
|
||||
|
||||
export function normalizeTelegramCommandDescription(value: string): string {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-runt
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramInlineButtonsConfigScope } from "./inline-buttons.js";
|
||||
import { normalizeTelegramChatId, resolveTelegramTargetChatType } from "./targets.js";
|
||||
@@ -107,7 +108,9 @@ function matchesTelegramRequestAccount(params: {
|
||||
accountId?: string | null;
|
||||
request: ExecApprovalRequest | PluginApprovalRequest;
|
||||
}): boolean {
|
||||
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
|
||||
params.request.request.turnSourceChannel,
|
||||
);
|
||||
const boundAccountId = resolveApprovalRequestChannelAccountId({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
|
||||
@@ -114,11 +114,6 @@ const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [
|
||||
"compacting",
|
||||
];
|
||||
|
||||
function normalizeEmoji(value: string | undefined): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function toUniqueNonEmpty(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
||||
}
|
||||
@@ -128,18 +123,18 @@ export function resolveTelegramStatusReactionEmojis(params: {
|
||||
overrides?: StatusReactionEmojis;
|
||||
}): Required<StatusReactionEmojis> {
|
||||
const { overrides } = params;
|
||||
const queuedFallback = normalizeEmoji(params.initialEmoji) ?? DEFAULT_EMOJIS.queued;
|
||||
const queuedFallback = normalizeOptionalString(params.initialEmoji) ?? DEFAULT_EMOJIS.queued;
|
||||
return {
|
||||
queued: normalizeEmoji(overrides?.queued) ?? queuedFallback,
|
||||
thinking: normalizeEmoji(overrides?.thinking) ?? DEFAULT_EMOJIS.thinking,
|
||||
tool: normalizeEmoji(overrides?.tool) ?? DEFAULT_EMOJIS.tool,
|
||||
coding: normalizeEmoji(overrides?.coding) ?? DEFAULT_EMOJIS.coding,
|
||||
web: normalizeEmoji(overrides?.web) ?? DEFAULT_EMOJIS.web,
|
||||
done: normalizeEmoji(overrides?.done) ?? DEFAULT_EMOJIS.done,
|
||||
error: normalizeEmoji(overrides?.error) ?? DEFAULT_EMOJIS.error,
|
||||
stallSoft: normalizeEmoji(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,
|
||||
stallHard: normalizeEmoji(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard,
|
||||
compacting: normalizeEmoji(overrides?.compacting) ?? DEFAULT_EMOJIS.compacting,
|
||||
queued: normalizeOptionalString(overrides?.queued) ?? queuedFallback,
|
||||
thinking: normalizeOptionalString(overrides?.thinking) ?? DEFAULT_EMOJIS.thinking,
|
||||
tool: normalizeOptionalString(overrides?.tool) ?? DEFAULT_EMOJIS.tool,
|
||||
coding: normalizeOptionalString(overrides?.coding) ?? DEFAULT_EMOJIS.coding,
|
||||
web: normalizeOptionalString(overrides?.web) ?? DEFAULT_EMOJIS.web,
|
||||
done: normalizeOptionalString(overrides?.done) ?? DEFAULT_EMOJIS.done,
|
||||
error: normalizeOptionalString(overrides?.error) ?? DEFAULT_EMOJIS.error,
|
||||
stallSoft: normalizeOptionalString(overrides?.stallSoft) ?? DEFAULT_EMOJIS.stallSoft,
|
||||
stallHard: normalizeOptionalString(overrides?.stallHard) ?? DEFAULT_EMOJIS.stallHard,
|
||||
compacting: normalizeOptionalString(overrides?.compacting) ?? DEFAULT_EMOJIS.compacting,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +143,7 @@ export function buildTelegramStatusReactionVariants(
|
||||
): Map<string, string[]> {
|
||||
const variantsByRequested = new Map<string, string[]>();
|
||||
for (const key of STATUS_REACTION_EMOJI_KEYS) {
|
||||
const requested = normalizeEmoji(emojis[key]);
|
||||
const requested = normalizeOptionalString(emojis[key]);
|
||||
if (!requested) {
|
||||
continue;
|
||||
}
|
||||
@@ -225,7 +220,7 @@ export function resolveTelegramReactionVariant(params: {
|
||||
variantsByRequestedEmoji: Map<string, string[]>;
|
||||
allowedEmojiReactions?: Set<TelegramReactionEmoji> | null;
|
||||
}): TelegramReactionEmoji | undefined {
|
||||
const requestedEmoji = normalizeEmoji(params.requestedEmoji);
|
||||
const requestedEmoji = normalizeOptionalString(params.requestedEmoji);
|
||||
if (!requestedEmoji) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user