diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..294c2977 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "clawmaster" + +[setup] +script = "npm install" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e822547e..b392142d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -354,9 +354,9 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_ref || github.ref }} - # Custom release-notes script walks the full commit range between - # the previous v* tag and the current tag via `git describe` + `git - # log`, which needs complete history and tags present. + # Generated release notes need complete history and tags so the + # workflow can choose the right previous tag for stable/prerelease + # comparisons. fetch-depth: 0 fetch-tags: true @@ -399,6 +399,38 @@ jobs: exit 1 fi + - name: Resolve release notes base tag + id: release_notes_base + env: + TAG: ${{ steps.release_meta.outputs.tag }} + run: | + set -euo pipefail + + previous_ancestor_tag="" + if git rev-parse --verify "${TAG}^{tag}" >/dev/null 2>&1 || git rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then + previous_ancestor_tag="$(git describe --tags --abbrev=0 --match 'v*' "${TAG}^" 2>/dev/null || true)" + fi + + previous_tag="$previous_ancestor_tag" + if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + previous_stable_tag="" + while IFS= read -r candidate; do + if [ "$candidate" = "$TAG" ]; then + break + fi + previous_stable_tag="$candidate" + done < <(git tag -l 'v*' --sort=v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true) + + first_release_tag="$(git tag -l 'v*' --sort=v:refname | head -n 1 || true)" + previous_tag="${previous_stable_tag:-$first_release_tag}" + fi + + if [ "$previous_tag" = "$TAG" ]; then + previous_tag="" + fi + + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + - name: Collect release assets run: | set -euo pipefail @@ -424,26 +456,15 @@ jobs: sha256sum * > SHA256SUMS.txt ) - - name: Create release notes + - name: Create release preface env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ steps.release_meta.outputs.tag }} run: | set -euo pipefail - # Custom script groups commits by conventional-commit type into - # emoji'd buckets. Beats `gh api .../generate-notes` for git-flow - # repos where the tagged branch only sees the release-merge commit. - GENERATED_BODY="$(node scripts/release-notes.mjs "$TAG")" - VERSION="${TAG#v}" - NPM_TAG="latest" - if printf '%s' "$VERSION" | grep -Eq -- '-(rc|beta|alpha)'; then - NPM_TAG="rc" - fi - RELEASE_URL="https://github.com/${GH_REPO}/releases/tag/${TAG}" RELEASE_DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${TAG}" NPM_PACKAGE_URL="https://www.npmjs.com/package/clawmaster/v/${VERSION}" NPM_DIST_URL="https://www.npmjs.com/package/clawmaster?activeTab=versions" @@ -481,13 +502,11 @@ jobs: printf '| macOS Apple Silicon | [dmg](%s) |\n' "$macos_arm64_url" printf '| Windows x64 | [msi](%s) · [exe](%s) |\n\n' "$windows_msi_url" "$windows_exe_url" printf '> Desktop builds are in beta. The CLI + Web Console is the recommended install method.\n\n' - printf "## What's Changed\n\n" - printf '%s\n' "$GENERATED_BODY" - } > RELEASE_NOTES.md + } > RELEASE_PREFACE.md - - name: Create or update GitHub release + - name: Resolve release options + id: release_options env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.release_meta.outputs.tag }} EVENT_NAME: ${{ github.event_name }} INPUT_DRAFT: ${{ inputs.draft_release }} @@ -495,44 +514,46 @@ jobs: run: | set -euo pipefail - DRAFT_FLAG="" - PRERELEASE_FLAG="" + draft="false" + prerelease="false" if [ "$EVENT_NAME" = "workflow_dispatch" ]; then if [ "$INPUT_DRAFT" = "true" ]; then - DRAFT_FLAG="--draft" + draft="true" fi if [ "$INPUT_PRERELEASE" = "true" ]; then - PRERELEASE_FLAG="--prerelease" + prerelease="true" fi else if printf '%s' "$TAG" | grep -Eq -- '-(beta|rc)'; then - PRERELEASE_FLAG="--prerelease" + prerelease="true" fi fi - # Pre-releases must not become the "latest" release on GitHub - LATEST_FLAG="--latest" - if [ -n "$PRERELEASE_FLAG" ]; then - LATEST_FLAG="--latest=false" + make_latest="true" + if [ "$prerelease" = "true" ]; then + make_latest="false" fi - if gh release view "$TAG" >/dev/null 2>&1; then - # Force un-draft so tag re-pushes produce a publicly-visible release - gh release edit "$TAG" \ - --title "ClawMaster $TAG" \ - --notes-file RELEASE_NOTES.md \ - --draft=false \ - $LATEST_FLAG - gh release upload "$TAG" release-assets/* --clobber - else - gh release create "$TAG" release-assets/* \ - --title "ClawMaster $TAG" \ - --notes-file RELEASE_NOTES.md \ - $DRAFT_FLAG \ - $PRERELEASE_FLAG \ - $LATEST_FLAG - fi + echo "draft=$draft" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "make_latest=$make_latest" >> "$GITHUB_OUTPUT" + + - name: Create or update GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.release_meta.outputs.tag }} + name: ClawMaster ${{ steps.release_meta.outputs.tag }} + bodyFile: RELEASE_PREFACE.md + generateReleaseNotes: true + generateReleaseNotesPreviousTag: ${{ steps.release_notes_base.outputs.previous_tag }} + artifacts: release-assets/* + artifactErrorsFailBuild: true + allowUpdates: true + replacesArtifacts: true + draft: ${{ steps.release_options.outputs.draft }} + prerelease: ${{ steps.release_options.outputs.prerelease }} + makeLatest: ${{ steps.release_options.outputs.make_latest }} - name: Add workflow summary env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e1fc733..1e6e3b5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -439,31 +439,11 @@ jobs: .catch(() => page.waitForSelector('text=Configure LLM Provider', { timeout: 5000 })); const skipLink = page.locator('text=跳过剩余步骤').or(page.locator('text=Skip')); await skipLink.first().click(); - const skipGatewayStep = page.locator('text=网关').or(page.locator('text=Gateway')); - await skipGatewayStep.first().waitFor({ timeout: 5000 }); - const startGatewayBtn = page.locator('text=启动网关').or(page.locator('text=Start Gateway')); - const checkGatewayBtn = page.locator('text=重新检查网关').or(page.locator('text=Check Again')); - const skipEnterBtn = page.locator('text=进入管理大师').or(page.locator('text=Enter ClawMaster')); + const skipGatewayStep = page.getByRole('heading', { name: /^(网关|Gateway|ゲートウェイ)$/ }); + await skipGatewayStep.waitFor({ timeout: 5000 }); // Skip only needs to prove the wizard lands on the mandatory gateway step - // and offers at least one valid next action from there. - let skipNextAction = await skipEnterBtn.first().isVisible().catch(() => false); - if (!skipNextAction) { - const canStartGateway = await startGatewayBtn.first().isVisible().catch(() => false); - if (canStartGateway) { - await startGatewayBtn.first().click(); - } else { - const canCheckGateway = await checkGatewayBtn.first().isVisible().catch(() => false); - if (canCheckGateway) { - await checkGatewayBtn.first().click(); - } - } - await page.waitForTimeout(2000); - const enterVisible = await skipEnterBtn.first().isVisible().catch(() => false); - const startVisible = await startGatewayBtn.first().isVisible().catch(() => false); - const checkVisible = await checkGatewayBtn.first().isVisible().catch(() => false); - skipNextAction = enterVisible || startVisible || checkVisible; - } - if (!skipNextAction) bail('Skip flow reached gateway step but exposed no valid next action'); + // instead of the optional channel steps. The gateway status check is + // asynchronous, so button visibility is intentionally not asserted here. ok('Skip flow reached mandatory gateway step'); console.log(`\nAll ${step} wizard E2E checks passed`); diff --git a/.gitignore b/.gitignore index e9e87aa7..d4d72762 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ src-tauri/Cargo.lock # IDE .vscode/ .idea/ +.codex/ *.swp *.swo *~ diff --git a/CHANGELOG.md b/CHANGELOG.md index df033429..da6e3bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] - See the open pull requests and merged changes on GitHub for work not yet released. + +## [0.4.0] - 2025-05-13 + +### Added + +- **Wiki / LLM Knowledge Base**: workspace module, knowledge service backend, and PowerMem integration for wiki recall and link ingest +- **Gateway-backed Wiki LLM workflows**: gateway-proxied LLM calls for wiki operations +- **GLM provider**: native GLM model support and integration guidance +- **Baidu Qianfan Coding Plan provider**: new provider for Baidu Qianfan coding models +- **Gateway watchdog**: health monitor for ClawMaster service mode +- **Package download tracker**: skill for tracking package download progress +- **Latest session cost on Observe**: show most recent session cost in the Observe dashboard +- **ClawMaster release notifications**: notify users of new releases + +### Fixed + +- **Windows process management**: use `taskkill` for process tree cleanup on Windows; resolve ENOENT for npm-installed CLI tools (`openclaw`, `clawprobe`, `npm`) +- **SIGTERM→SIGKILL grace period**: restore grace period on non-Windows timeout +- **WeChat QR login**: fix cancellation and recovery flow +- **PaddleOCR OCR configuration UX**: clarify configuration surface +- **Settings update section**: improve settings update UI +- **Security audit advisories**: remediate reported vulnerabilities +- **Windows WSL**: fix workspace path and Vitest workers for WSL environments + +### Changed + +- **Wiki module repositioned**: wiki module is now positioned as LLM knowledge base with consolidated PowerMem integration +- **Release workflow**: use release-action for GitHub releases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7af6485e..9e7ef98e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,12 +28,12 @@ For desktop (Tauri) development, see the Rust/Tauri section in `CLAUDE.md`. ## Branch and PR Workflow 1. **Fork** the repository and clone your fork. -2. Create a **feature branch** from `main`: +2. Create a **feature branch** from `develop`: ```bash - git checkout -b feat/my-feature main + git checkout -b feat/my-feature develop ``` 3. Make your changes, commit with conventional messages (see below), and push. -4. Open a **Pull Request** against `main` in the upstream repo. +4. Open a **Pull Request** against `develop` in the upstream repo. 5. Fill in the PR template. Link related issues with `Closes #123`. Keep PRs focused -- one logical change per PR. @@ -46,6 +46,61 @@ Keep PRs focused -- one logical change per PR. - New features should be built as **capability modules** in `packages/web/src/modules/` (see `CLAUDE.md` for the `ClawModule` pattern). - Use split adapters in `shared/adapters/` and the `useAdapterCall` hook for data fetching. +## Adding or Updating Model Providers + +ClawMaster is a UI and local service layer for OpenClaw. A provider should usually be supported by OpenClaw first; ClawMaster then makes that provider easy to discover, validate, and configure in the setup wizard and Models page. + +Before opening a provider PR: + +- Open or link an issue that identifies the provider, API docs, default base URL, API-key page, supported model IDs, and whether the API is native OpenAI-compatible. +- Confirm the OpenClaw runtime provider id. Use that id in ClawMaster model refs (`provider/model`) unless there is already a documented alias. +- If the vendor docs include an OpenClaw config snippet, copy the exact provider id, `baseUrl`, `api` mode, and default model into the issue before coding. +- Test credentials may be used for local smoke checks, but never commit real keys, screenshots that reveal keys, terminal logs containing keys, or `.env` files. +- Do not add dependencies for provider integration. Provider setup should use existing adapters, fetch helpers, and config writers. + +Provider UI source of truth: + +- Add the provider to `packages/web/src/modules/setup/types.ts` in `PROVIDERS`. +- Include `label`, `keyUrl`, `models`, `defaultModel`, and `baseUrl` when the endpoint is fixed. +- Set `api: 'openai-completions'` for OpenAI-compatible chat/completions providers that OpenClaw should persist with that API mode. +- Use `labelByLocale` and `credentialLabelByLocale` only when the display name or credential name needs localization beyond the default English label and `API Key`. +- Add the provider id to `TEXT_PROVIDER_TIERS` so it appears in both the setup wizard and Models add-provider dialog. Image-only providers must use `kind: 'text-to-image'` and belong in `PRIMARY_IMAGE_PROVIDERS` instead. +- Use `runtimeProviderId` only when the UI entry intentionally writes to another OpenClaw provider key, such as a text-to-image variant sharing a chat provider account. +- Use `configKeyOverride` only for legacy OpenClaw config compatibility. + +Live model catalogs: + +- Add the provider default base URL to both `packages/web/src/shared/providerCatalog.ts` and `packages/backend/src/services/providerCatalogService.ts` when the Models page should fetch `/models` in desktop and web modes. +- Keep catalog allowlists strict. Non-custom providers must only accept their documented host, protocol, port, and base path. The custom OpenAI-compatible provider is the only path that may accept arbitrary public hosts. +- Add response filtering when a provider returns embeddings, image models, OCR models, moderation models, or other non-chat entries in the same catalog. +- If `/models` returns a broad catalog or omits a documented default alias, keep the documented fallback `models` list in `PROVIDERS` and filter the live catalog to the provider's intended capability. For example, coding-plan providers should not surface unrelated image, OCR, embedding, or generic chat entries. +- Keep frontend and backend catalog behavior equivalent; the backend service powers web mode, while the frontend helper powers Tauri mode. + +Validation and persistence: + +- Provider key validation is implemented in `packages/web/src/modules/setup/adapters.ts`. +- OpenAI-compatible providers should work through the existing chat/completions probe and `/models` fallback. Do not add provider-specific HTTP code unless the provider is not compatible with the common flow. +- Smoke-test the documented default model with `POST /chat/completions` and, when catalog support is enabled, `GET /models`. Record only status and sanitized findings in the issue or PR. +- Saved provider config should include `apiKey`, `baseUrl`, `api` when needed, and the static fallback `models` list. The Models page uses that saved list when live catalog discovery is unavailable. +- Default model refs must use the OpenClaw runtime provider id, for example `zai/glm-5.1`. + +Tests required for provider PRs: + +- `packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx`: provider appears in the wizard and passes the expected provider id, key, and base URL into validation. +- `packages/web/src/modules/models/__tests__/ModelsPage.test.tsx`: provider appears in the add dialog and configured-provider card, including live catalog behavior when supported. +- `packages/web/src/modules/setup/__tests__/realAdapter.test.ts`: saved config shape includes the expected API mode, base URL, and fallback models. +- `packages/web/src/shared/providerCatalog.test.ts`: catalog request URL, safety checks, and response filtering. +- `packages/backend/src/services/providerCatalogService.test.ts`: matching backend catalog behavior for web mode. + +Run at least: + +```bash +(cd packages/web && npx vitest run src/shared/providerCatalog.test.ts src/modules/setup/__tests__/realAdapter.test.ts src/modules/setup/__tests__/SetupWizard.test.tsx src/modules/models/__tests__/ModelsPage.test.tsx) +npm test --workspace=@openclaw-manager/backend +npm run build --workspace=@openclaw-manager/web +npm run build --workspace=@openclaw-manager/backend +``` + ## i18n Rules (internationalization / 国际化) All user-facing UI text **must** go through the `t()` translation function. Hard-coded strings in components are not accepted. @@ -101,7 +156,7 @@ Every pull request must be tested before review: > [!WARNING] > PRs containing any of the following will be asked to remove them before merge: -- **No screenshots or screen recordings** — post demos in [Discussions](https://github.com/openmaster-ai/clawmaster/discussions) instead. +- **No committed screenshots or screen recordings** — UI PRs should include screenshots or short recordings in the PR body under **## Screenshots**, but those assets must not be committed to the repo. - **No test output logs** or captured terminal output pasted inline. - **No debug `console.log` calls** left in production code paths. - **No generated files**: `dist/`, `coverage/`, `*.tsbuildinfo`, `src-tauri/target/`. diff --git a/README.md b/README.md index 3d64e1f1..0c84ed16 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@   OpenMaster Universe Brand ClawMaster + Workshop

@@ -27,6 +28,7 @@

Build + Next milestone: v0.4.0 Stars Apache 2.0

@@ -35,8 +37,7 @@ 📦 Releases  ·  💬 Discussions  ·  🐛 Issues  ·  - 📘 Ask DeepWiki  ·  - Discord + 📘 Ask DeepWiki   |   English  ·  中文  ·  日本語

@@ -59,7 +60,7 @@ clawmaster doctor # verify your environment ``` > [!NOTE] -> The current release is **v0.3.0**. Install with `npm i -g clawmaster`. +> The current release is **v0.3.1**. The next milestone is [**v0.4.0**](https://github.com/openmaster-ai/clawmaster/milestone/1) — already shipping features land there as they merge. ### Desktop App (Beta) @@ -97,57 +98,33 @@ Requires Node.js 20+. Tauri desktop builds also need Rust — see [tauri.app/sta 3. Add channels, plugins, skills, or MCP servers as needed. 4. Enable gateway or observability when you need runtime inspection. -## Why ClawMaster - -Most OpenClaw tooling stops at configuration. ClawMaster is your **OpenClaw companion for real life** — it goes beyond setup to help normal, non-technical users actually make practical use of OpenClaw as a digital personal assistant. - -That means ClawMaster is not only for: -- editing config safely, -- connecting models and channels, -- monitoring runtime health, - -but also for: -- making setup approachable, -- turning advanced agent capability into guided workflows, -- and gradually adding more guided learning and workflow support for real daily work and life goals. - -**Positioning:** ClawMaster is the bridge between OpenClaw's power and everyday usability. +### Pick your learning path -## ClawMaster vs. CLI Only +- 🧪 **Hands-on** — run through [**clawmaster-workshop**](https://github.com/openmaster-ai/clawmaster-workshop) — trilingual (EN / 中文 / 日本語) tasks grouped by the six core capabilities, plus dated labs that chain tasks into real scenarios. Best if you want to *do* the thing. +- 🖼️ **Pictured walkthrough** — skim the [Product Tour](#product-tour) below. Each screenshot maps to a concrete task, so you can understand what the product does without installing anything. -| | OpenClaw CLI alone | ClawMaster | -|---|---|---| -| Initial setup | Hand-edit `~/.openclaw/openclaw.json` | Guided wizard | -| Provider & model config | Edit JSON, restart | Form UI with live validation | -| Channel setup | Read docs, edit config | Step-by-step guides per platform | -| Observability | Mostly CLI and logs | ClawProbe-backed dashboard and runtime views | -| Memory management | `powermem` CLI | Management UI | -| Daily-use enablement | Mostly DIY | Product UX that is moving toward more guided use | -| Multiple profiles | Manual file juggling | Profile switcher | -| Desktop app | No | Yes — ships as `.dmg` / `.msi` / `.AppImage` | -| Self-hosted web console | No | Yes — Express, runs anywhere Node.js runs | +## Why ClawMaster -## Who It Is For +Most OpenClaw tooling stops at configuration. ClawMaster is your **OpenClaw companion for real life** — a bridge between OpenClaw's power and everyday usability. It's for people who want OpenClaw to actually work in their daily life (not just be correctly configured), who don't want to live in JSON and terminals, and who manage OpenClaw for a team or family. -**"I want OpenClaw to be useful in my real life, not just correctly configured."** -ClawMaster is designed to reduce the gap between installation and actual outcomes. +## Memory Highlights -**"I'm non-technical, but I still want a powerful AI personal assistant."** -The product is moving toward guided setup, guided usage, and outcome-oriented learning instead of assuming comfort with JSON, terminals, or infra concepts. +Memory is the backbone of the **Save** capability. We build on [**PowerMem**](https://github.com/oceanbase/powermem) ([Python](https://github.com/oceanbase/powermem) · [TypeScript SDK](https://github.com/ob-labs/powermem-ts) · [OpenClaw plugin](https://github.com/ob-labs/memory-powermem)) instead of rolling our own: -**"I manage OpenClaw for my team or family."** -One place to configure channels, inspect runtime state, and make the stack easier for others to adopt. +- **Native OpenClaw citizen** — PowerMem already ships an OpenClaw memory plugin, so agent turns get auto-recall / auto-capture for free. +- **Smart extraction, not chunk dumping** — distills conversations into durable facts with Ebbinghaus-style decay and recall, which matches our "build but also maintain" direction. +- **Multi-agent isolation built in** — scopes per user / agent / workspace without us reinventing identity plumbing. +- **Database-grade durability** — pairs with [OceanBase seekdb](https://github.com/oceanbase/seekdb) for hybrid vector + full-text + SQL, with SQLite as a cross-platform fallback. +- **Open source with cross-language SDKs** — we're not locked into one runtime; consistent semantics from JS to Python to Go. -**"I'm building advanced agent workflows."** -You still get provider management, observability, memory tooling, sessions, plugins, skills, and MCP in one place. +**Shipped** -## What You Can Do Today +- Managed PowerMem runtime with an OpenClaw bridge across web, backend, and desktop — agent turns get auto-recall and auto-capture out of the box. +- Local workspace import that pulls markdown / `memory/` into managed PowerMem, using seekdb where available and SQLite as a fallback. +- First end-to-end memory-backed skill: a daily package download digest with period-over-period deltas. +- Memory-adjacent observability — per-session spend, scheduled cost digests, and models.dev pricing. -- **Setup and profiles** — Detect OpenClaw, install missing pieces, create or switch profiles, bootstrap a local environment. -- **Models and providers** — Configure OpenAI-compatible and provider-specific endpoints, validate API keys, set runtime defaults. -- **Gateway and channels** — Bring up the gateway, follow guided setup for Feishu, WeChat, Discord, Slack, Telegram, and WhatsApp. -- **Plugins, skills, and MCP** — Enable or disable capabilities, install curated items, add MCP servers, import MCP definitions. -- **Sessions, memory, and observability** — Inspect sessions, manage memory backends, track token usage and estimated spend. +**Next (v0.4.0)**: full seekdb hybrid retrieval and a self-maintaining LLM Knowledge module — persistent knowledge pages that compound with every ingest, with Ebbinghaus decay and freshness-weighted ranking keeping content alive. See the [v0.4.0 milestone](https://github.com/openmaster-ai/clawmaster/milestone/1) for tracked work. ## Product Tour @@ -177,7 +154,7 @@ You still get provider management, observability, memory tooling, sessions, plug Memory workspace
- Memory · PowerMem-backed knowledge workspace + Memory · PowerMem runtime with seekdb / SQLite fallback MCP servers page
@@ -190,6 +167,13 @@ You still get provider management, observability, memory tooling, sessions, plug +## Who It Is For + +- **"I want OpenClaw useful in real life, not just configured."** — closes the gap between install and outcome. +- **"I'm non-technical but want a powerful AI assistant."** — guided setup, guided usage, no JSON required. +- **"I manage OpenClaw for my team or family."** — one place for channels, runtime state, and onboarding. +- **"I'm building advanced agent workflows."** — provider management, observability, memory, sessions, plugins, skills, and MCP in one place. + ## Roadmap Six core capabilities — each moves from infrastructure toward real daily use: @@ -198,7 +182,7 @@ Six core capabilities — each moves from infrastructure toward real daily use: |---|---|---|---|---| | 1 | **Setup** | Available | Guided wizard, 6+ LLM providers with key validation, 6 channel types (Feishu / WeChat / Discord / Slack / Telegram / WhatsApp), profile switching | One-click environment migration ([#1](https://github.com/openmaster-ai/clawmaster/issues/1)), Windows + WSL2 first-class support | | 2 | **Observe** | Available | ClawProbe-backed dashboard, per-session cost and token tracking, gateway health monitoring | Historical spend analytics, anomaly alerts, multi-profile comparison | -| 3 | **Save** | In progress | PowerMem UI with FTS5 local search, memory workspace management, graceful fallback to markdown grep | Full seekdb vector retrieval ([#12](https://github.com/openmaster-ai/clawmaster/issues/12)), LLM Wiki — persistent knowledge base that compounds over time ([#49](https://github.com/openmaster-ai/clawmaster/issues/49)) | +| 3 | **Save** | In progress | Managed PowerMem runtime + OpenClaw bridge, local workspace import, first memory-backed skill — see [Memory Highlights](#memory-highlights) | Full seekdb hybrid retrieval, self-maintaining LLM Knowledge module — see [v0.4.0 milestone](https://github.com/openmaster-ai/clawmaster/milestone/1) | | 4 | **Apply** | In progress | PaddleOCR pipeline (upload → parse → structured markdown), layout-aware extraction | Photo → flashcard automation, invoice extraction templates, more scenario-first guided workflows | | 5 | **Build** | Planned | Plugin/skill install and toggle, MCP server management, skill security auditing | Visual agent composer for skill chaining, LangChain Deep Agents integration, conversational agent builder | | 6 | **Guard** | Planned | Skill Guard security scanning (dimension/severity/risk scoring), basic capability gating | API key vault (encrypted at rest), per-profile spend caps, RBAC for team deployments | @@ -294,8 +278,8 @@ Community: [GitHub Discussions](https://github.com/openmaster-ai/clawmaster/disc |---|---| | [OpenClaw](https://github.com/openclaw/openclaw) | Core runtime and configuration model | | [ClawProbe](https://github.com/openclaw/clawprobe) | Observability daemon | -| [PowerMem](https://github.com/openclaw/powermem) | Memory backend | -| [seekdb](https://github.com/openclaw/seekdb) | Retrieval and search workflows | +| [PowerMem](https://github.com/oceanbase/powermem) · [TS SDK](https://github.com/ob-labs/powermem-ts) | Memory backend | +| [OceanBase seekdb](https://github.com/oceanbase/seekdb) | Retrieval and search workflows | | [Tauri](https://tauri.app) | Desktop app framework | | [React](https://react.dev) | Frontend UI | | [Vite](https://vitejs.dev) | Frontend toolchain | diff --git a/README_CN.md b/README_CN.md index 34d604da..055caab2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,6 +17,7 @@   OpenMaster Universe Brand ClawMaster + Workshop

@@ -27,6 +28,7 @@

Build + 下一个里程碑: v0.4.0 Stars Apache 2.0

@@ -35,8 +37,7 @@ 📦 Releases  ·  💬 Discussions  ·  🐛 Issues  ·  - 📘 Ask DeepWiki  ·  - Discord + 📘 Ask DeepWiki   |   English  ·  中文  ·  日本語

@@ -59,7 +60,7 @@ clawmaster doctor # 检查环境 ``` > [!NOTE] -> 当前版本为 **v0.3.0**。安装命令:`npm i -g clawmaster`。 +> 当前版本为 **v0.3.1**。下一个里程碑是 [**v0.4.0**](https://github.com/openmaster-ai/clawmaster/milestone/1) —— 已经合并的功能会随发布一起落地。 ### 桌面应用(Beta 测试版) @@ -97,57 +98,33 @@ npm run tauri:dev # 桌面应用 3. 按需添加频道、插件、技能或 MCP 服务。 4. 如需运行时观测,启用网关或可观测模块。 -## 为什么是 ClawMaster - -大多数 OpenClaw 工具,重点都停留在”把配置配对”。ClawMaster 是你**真正走进日常生活的 OpenClaw 伙伴** —— 不仅帮助用户完成配置,更重要的是帮助普通、非技术用户,开始把 OpenClaw 实际用于日常工作与生活。 - -这意味着 ClawMaster 不只是: -- 更安全地编辑配置, -- 更方便地连接模型和频道, -- 更直观地观察运行状态, - -还要进一步做到: -- 让上手过程更友好, -- 把复杂能力包装成可理解、可执行的引导式流程, -- 逐步补充更清晰的引导、教学与工作流支持,帮助用户完成真实的工作与生活目标。 - -**一句话定位:** ClawMaster 是连接 OpenClaw 强大能力与日常可用性的桥梁。 +### 选一条上手路径 -## ClawMaster vs. 纯 CLI +- 🧪 **动手实操** —— 跟着 [**clawmaster-workshop**](https://github.com/openmaster-ai/clawmaster-workshop) 练一遍 —— 三语(EN / 中文 / 日本語)任务按六大核心能力分组,还有把任务串成真实场景的日期化实验。想*动手做*的首选。 +- 🖼️ **图示导览** —— 看下面的[产品功能总览](#产品功能总览),每张截图对应一个具体任务,不装就能看懂产品在做什么。 -| | 仅 OpenClaw CLI | ClawMaster | -|---|---|---| -| 初始安装 | 手动编辑 `~/.openclaw/openclaw.json` | 向导引导完成 | -| 供应商与模型配置 | 编辑 JSON,重启 | 表单 UI,实时校验 | -| 频道接入 | 查阅文档,手动配置 | 各平台逐步引导 | -| 可观测性 | 主要依赖 CLI 与日志 | 基于 ClawProbe 的面板与运行态视图 | -| 记忆管理 | `powermem` CLI | 管理 UI | -| 日常使用引导 | 主要靠自己摸索 | 正在逐步增强引导式体验 | -| 多 Profile | 手动管理文件 | Profile 切换器 | -| 桌面应用 | 无 | 有 — 提供 `.dmg` / `.msi` / `.AppImage` | -| 自托管 Web 控制台 | 无 | 有 — Express,任何 Node.js 环境均可运行 | +## 为什么是 ClawMaster -## 适合谁 +大多数 OpenClaw 工具都停在“把配置配对”。ClawMaster 是你**真正走进日常生活的 OpenClaw 伙伴** —— 是连接 OpenClaw 强大能力与日常可用性的桥梁。它面向这样的用户:想让 OpenClaw 在日常生活里真的有用(而不只是配好),不想天天跟 JSON 和终端打交道,或在替团队、家人管理 OpenClaw。 -**「我不想只是把 OpenClaw 配好,我想让它真的能帮我做事。」** -ClawMaster 的核心目标,是缩短“安装完成”到“真实产出”之间的距离。 +## 记忆亮点 -**「我不是技术人员,但我也想拥有强大的 AI 私人助理。」** -产品会越来越强调引导式安装、引导式使用、结果导向学习,而不是默认用户熟悉 JSON、命令行和基础设施。 +记忆是**能省钱**能力的主干。我们基于 [**PowerMem**](https://github.com/oceanbase/powermem)([Python](https://github.com/oceanbase/powermem) · [TypeScript SDK](https://github.com/ob-labs/powermem-ts) · [OpenClaw 插件](https://github.com/ob-labs/memory-powermem))构建,而不是自己重造: -**「我在帮团队、家人或客户管理 OpenClaw。」** -一个地方完成频道配置、运行状态查看,也让其他人更容易真正上手。 +- **原生 OpenClaw 公民** —— PowerMem 自带 OpenClaw 记忆插件,智能体每一轮自动 recall / capture。 +- **智能抽取,而不是堆积 chunk** —— 把对话蒸馏成持久事实,并用艾宾浩斯衰减模型驱动回忆,与我们“建了也要养”的方向高度契合。 +- **多智能体隔离开箱即用** —— 按用户 / 智能体 / 工作区自动隔离,无需自己搭身份系统。 +- **数据库级持久化** —— 与 [OceanBase seekdb](https://github.com/oceanbase/seekdb) 搭配可做向量 + 全文 + SQL 混合检索,SQLite 作为跨平台兜底。 +- **开源、多语言 SDK** —— 不绑定单一运行时;从 JS 到 Python 到 Go 的语义一致。 -**「我也需要更专业的能力管理界面。」** -你仍然可以获得模型管理、可观测、记忆、会话、插件、技能和 MCP 的完整能力。 +**已经上线** -## 现在已经能做什么 +- 托管 PowerMem 运行时 + OpenClaw 桥接,覆盖 Web、后端和桌面 —— 智能体每一轮开箱即用地自动 recall / capture。 +- 本地工作区导入 —— 把 markdown / `memory/` 导入托管 PowerMem,有 seekdb 时用 seekdb,其他情况降级到 SQLite。 +- 首个端到端记忆驱动技能:每日 npm 包下载摘要,支持周期同比对比。 +- 记忆相关的可观测:按会话花费、定时费用摘要、models.dev 定价。 -- **安装与 Profile** —— 检测 OpenClaw、安装缺失组件、创建或切换 Profile,快速引导到可用环境。 -- **模型与供应商** —— 配置 OpenAI 兼容或各家专有端点,校验 API Key,设置默认模型。 -- **网关与频道** —— 启动网关,跟随飞书、微信、Discord、Slack、Telegram、WhatsApp 的逐步接入向导。 -- **插件、技能与 MCP** —— 启用 / 禁用能力,安装精选项目,添加 MCP 服务,导入 MCP 定义。 -- **会话、记忆与可观测** —— 查看会话,管理记忆后端,追踪 Token 用量和费用估算。 +**下一步(v0.4.0)**:完整的 seekdb 混合检索,以及自维护的 LLM 知识库模块 —— 每次写入都会让知识页面自动交叉链接并积累,艾宾浩斯衰减与新鲜度加权让内容保持"活着"。具体进展见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1)。 ## 产品功能总览 @@ -177,7 +154,7 @@ ClawMaster 的核心目标,是缩短“安装完成”到“真实产出”之 记忆工作区
- 记忆 · PowerMem 驱动的知识工作区 + 记忆 · PowerMem 运行时,支持 seekdb / SQLite 降级 MCP 服务器
@@ -190,15 +167,22 @@ ClawMaster 的核心目标,是缩短“安装完成”到“真实产出”之 +## 适合谁 + +- **「想让 OpenClaw 真的能帮我做事,不只是配好。」** —— 缩短“安装完成”到“真实产出”的距离。 +- **「我不是技术人员,但也想有强大的 AI 助理。」** —— 引导式安装、引导式使用,不要求你懂 JSON。 +- **「我在帮团队或家人管 OpenClaw。」** —— 一个地方搞定频道、运行态、上手流程。 +- **「我在搭高级智能体工作流。」** —— 模型、可观测、记忆、会话、插件、技能、MCP 一站式。 + ## 路线图 六大核心能力 —— 每一项都从基础设施走向日常可用: | # | 能力 | 状态 | 已有 | 下一步 | |---|---|---|---|---| -| 1 | **能接管** | 可用 | 引导式向导、6+ LLM 供应商并校验 Key、6 种频道(飞书 / 微信 / Discord / Slack / Telegram / WhatsApp)、Profile 切换 | 一键环境迁移([#1](https://github.com/openmaster-ai/clawmaster/issues/1))、Windows + WSL2 一等支持 | +| 1 | **能接管** | 可用 | 引导式向导、6+ LLM 供应商并校验 Key、6 种频道(飞书 / 微信 / Discord / Slack / Telegram / WhatsApp)、Profile 切换 | 一键环境迁移、Windows + WSL2 一等支持 | | 2 | **能观测** | 可用 | 基于 ClawProbe 的面板、按会话的费用与 Token 追踪、网关健康监控 | 历史花费分析、异常告警、多 Profile 对比 | -| 3 | **能省钱** | 进行中 | PowerMem UI + FTS5 本地搜索、记忆工作区管理、自动降级到 markdown grep | 完整 seekdb 向量检索([#12](https://github.com/openmaster-ai/clawmaster/issues/12))、LLM Wiki —— 持续积累的知识库([#49](https://github.com/openmaster-ai/clawmaster/issues/49)) | +| 3 | **能省钱** | 进行中 | 托管 PowerMem 运行时 + OpenClaw 桥接、本地工作区导入、首个记忆驱动技能 —— 详见[记忆亮点](#记忆亮点) | 完整 seekdb 混合检索、自维护 LLM 知识库模块 —— 详见 [v0.4.0 里程碑](https://github.com/openmaster-ai/clawmaster/milestone/1) | | 4 | **能应用** | 进行中 | PaddleOCR 流水线(上传 → 解析 → 结构化 Markdown)、版面感知提取 | 拍照 → 闪卡自动生成、发票提取模板、更多场景优先的引导式工作流 | | 5 | **能构建** | 规划中 | 插件 / 技能安装与开关、MCP 服务管理、技能安全审计 | 可视化智能体编排器、LangChain Deep Agents 集成、对话式智能体构建 | | 6 | **能守护** | 规划中 | Skill Guard 安全扫描(维度 / 严重性 / 风险评分)、基础能力门控 | API Key 加密保险箱、按 Profile 的花费上限、团队部署 RBAC | @@ -291,8 +275,8 @@ clawmaster/ |---|---| | [OpenClaw](https://github.com/openclaw/openclaw) | 核心运行时与配置模型 | | [ClawProbe](https://github.com/openclaw/clawprobe) | 可观测守护进程 | -| [PowerMem](https://github.com/openclaw/powermem) | 记忆后端 | -| [seekdb](https://github.com/openclaw/seekdb) | 检索与搜索工作流 | +| [PowerMem](https://github.com/oceanbase/powermem) · [TS SDK](https://github.com/ob-labs/powermem-ts) | 记忆后端 | +| [OceanBase seekdb](https://github.com/oceanbase/seekdb) | 检索与搜索工作流 | | [Tauri](https://tauri.app) | 桌面应用框架 | | [React](https://react.dev) | 前端 UI | | [Vite](https://vitejs.dev) | 前端工具链 | diff --git a/README_JP.md b/README_JP.md index f3ca54e3..af6336a5 100644 --- a/README_JP.md +++ b/README_JP.md @@ -17,6 +17,7 @@   OpenMaster Universe Brand ClawMaster + Workshop

@@ -27,6 +28,7 @@

Build + 次のマイルストーン: v0.4.0 Stars Apache 2.0

@@ -35,8 +37,7 @@ 📦 Releases  ·  💬 Discussions  ·  🐛 Issues  ·  - 📘 Ask DeepWiki  ·  - Discord + 📘 Ask DeepWiki   |   English  ·  中文  ·  日本語

@@ -59,7 +60,7 @@ clawmaster doctor # 環境を確認 ``` > [!NOTE] -> 現在のバージョンは **v0.3.0** です。インストール: `npm i -g clawmaster` +> 現在のバージョンは **v0.3.1** です。次のマイルストーンは [**v0.4.0**](https://github.com/openmaster-ai/clawmaster/milestone/1) —— マージ済みの機能はそこに集約されます。 ### デスクトップアプリ(Beta) @@ -97,57 +98,33 @@ npm run tauri:dev # デスクトップアプリ 3. 必要に応じてチャンネル、プラグイン、スキル、MCP サーバーを追加します。 4. ランタイムの観測が必要な場合は、ゲートウェイまたは可観測機能を有効にします。 -## なぜ ClawMaster なのか - -多くの OpenClaw ツールは、設定を整えるところで止まります。ClawMaster は**日常で使える OpenClaw の相棒**です — 設定を助けるだけでなく、一般の非技術ユーザーが OpenClaw を日常の仕事や生活で実際に使い始められるようにすることが重要な目的です。 - -つまり ClawMaster は、単に: -- 設定を安全に編集するための UI、 -- モデルやチャンネルを接続するための画面、 -- ランタイムを監視するためのダッシュボード、 - -で終わるのではなく、さらに: -- 初期導入をわかりやすくし、 -- 高度な機能をガイド付きの体験に変え、 -- 今後は、より明確なガイド、学習導線、ワークフロー支援も段階的に加えていきます。 - -**ひと言で言えば:** ClawMaster は、OpenClaw の強力さと日常での使いやすさをつなぐ橋です。 +### 学び方を選ぶ -## ClawMaster vs. CLI のみ +- 🧪 **ハンズオン** —— [**clawmaster-workshop**](https://github.com/openmaster-ai/clawmaster-workshop) を順に進めてください。3 言語(EN / 中文 / 日本語)のタスクが 6 つのコア機能に沿ってまとめられ、タスクを連結した日付付きラボもあります。*実際に手を動かす*のに最適。 +- 🖼️ **図示ツアー** —— 下の[プロダクト機能ツアー](#プロダクト機能ツアー)を眺めるだけ。各スクリーンショットが具体的なタスクに対応しているので、インストールしなくても全体像を掴めます。 -| | OpenClaw CLI のみ | ClawMaster | -|---|---|---| -| 初期セットアップ | `~/.openclaw/openclaw.json` を手編集 | ガイド付きウィザード | -| プロバイダー・モデル設定 | JSON を編集して再起動 | フォーム UI とライブバリデーション | -| チャンネル接続 | ドキュメントを読んで手動設定 | プラットフォームごとのステップガイド | -| 可観測性 | 主に CLI とログ | ClawProbe ベースのダッシュボードとランタイム表示 | -| メモリ管理 | `powermem` CLI | 管理 UI | -| 日常利用の支援 | 基本は自力 | よりガイド付きの体験へ拡張中 | -| 複数 Profile | ファイルを手動管理 | Profile スイッチャー | -| デスクトップアプリ | なし | あり — `.dmg` / `.msi` / `.AppImage` を提供 | -| セルフホスト Web コンソール | なし | あり — Express、Node.js 環境ならどこでも動作 | +## なぜ ClawMaster なのか -## こんな方に +多くの OpenClaw ツールは設定を整えるところで止まります。ClawMaster は**日常で使える OpenClaw の相棒** —— OpenClaw の強力さと日常での使いやすさをつなぐ橋です。OpenClaw を設定するだけでなく実生活で役立てたい人、JSON やターミナルに触れ続けたくない人、チームや家族のために OpenClaw を管理している人のための製品です。 -**「OpenClaw を正しく設定するだけでなく、実生活で役立てたい。」** -ClawMaster は、インストール完了から実際の成果までの距離を縮めるための製品です。 +## メモリハイライト -**「技術者ではないが、強力な AI パーソナルアシスタントを使いたい。」** -JSON、ターミナル、インフラ前提ではなく、ガイド付きセットアップ、ガイド付き活用、成果ベースの学習へ寄せていきます。 +メモリは**能節約**機能の背骨です。独自実装ではなく [**PowerMem**](https://github.com/oceanbase/powermem)([Python](https://github.com/oceanbase/powermem) · [TypeScript SDK](https://github.com/ob-labs/powermem-ts) · [OpenClaw プラグイン](https://github.com/ob-labs/memory-powermem))を基盤に採用しています: -**「チームや家族のために OpenClaw を管理している。」** -チャンネル設定やランタイム状況の確認を 1 か所で行え、ほかの人にも導入しやすくなります。 +- **ネイティブな OpenClaw 市民** —— PowerMem には OpenClaw 向けメモリプラグインが最初から備わっており、エージェントのターンごとに自動 recall / capture が行われます。 +- **チャンク投棄ではなく賢い抽出** —— 会話を持続的な事実に蒸留し、エビングハウス減衰で想起を駆動します。私たちの「作ったら育てる」方向性に合致します。 +- **マルチエージェントの分離が最初から** —— ユーザー / エージェント / ワークスペース単位で自動分離。自分で ID 基盤を再発明する必要はありません。 +- **データベース級の永続性** —— [OceanBase seekdb](https://github.com/oceanbase/seekdb) と組み合わせてベクトル + 全文 + SQL のハイブリッド検索、クロスプラットフォームでは SQLite にフォールバック。 +- **オープンソースで多言語 SDK** —— 特定ランタイムに縛られず、JS から Python、Go まで一貫したセマンティクス。 -**「高度なエージェント運用もしたい。」** -モデル管理、可観測性、メモリ、セッション、プラグイン、スキル、MCP を引き続き 1 つの場所で扱えます。 +**実装済み** -## いまできること +- 管理対象 PowerMem ランタイム + OpenClaw ブリッジを Web・バックエンド・デスクトップに展開 —— エージェントのターンで自動 recall / capture がそのまま動きます。 +- ローカルワークスペースインポート —— markdown / `memory/` を管理対象 PowerMem に取り込み、seekdb が使える場合は seekdb、それ以外は SQLite にフォールバック。 +- 管理対象メモリで動く初のエンドツーエンドスキル:npm ダウンロード日次ダイジェストと期間比較。 +- メモリ近傍の可観測性:セッション単位のコスト、スケジュール済みコストダイジェスト、models.dev 価格情報。 -- **セットアップと Profile** — OpenClaw の検出、不足コンポーネントの導入、Profile の作成・切り替え、ローカル環境の初期構築。 -- **モデルとプロバイダー** — OpenAI 互換エンドポイントや各種プロバイダーの設定、API キーの検証、デフォルトモデルの指定。 -- **ゲートウェイとチャンネル** — ゲートウェイの起動、Feishu・WeChat・Discord・Slack・Telegram・WhatsApp のガイド付き接続設定。 -- **プラグイン・スキル・MCP** — 機能の有効化 / 無効化、注目項目のインストール、MCP サーバーの追加、MCP 定義のインポート。 -- **セッション・メモリ・可観測性** — セッションの確認、メモリバックエンドの管理、トークン使用量とコスト見積もりの追跡。 +**次(v0.4.0)**:完全な seekdb ハイブリッド検索と、自己保守する LLM ナレッジモジュール —— 取り込みごとに交差リンクされて積み上がる永続ページ、エビングハウス減衰と新鮮度重み付けで内容を生かし続けます。詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) を参照。 ## プロダクト機能ツアー @@ -177,7 +154,7 @@ JSON、ターミナル、インフラ前提ではなく、ガイド付きセッ メモリワークスペース
- メモリ · PowerMem ベースのナレッジワークスペース + メモリ · PowerMem ランタイム + seekdb / SQLite フォールバック MCP サーバー
@@ -190,15 +167,22 @@ JSON、ターミナル、インフラ前提ではなく、ガイド付きセッ +## こんな方に + +- **「ただ設定するだけでなく、実生活で使いたい。」** —— インストールから成果までの距離を縮めます。 +- **「技術者ではないが強力な AI アシスタントが欲しい。」** —— ガイド付きセットアップと活用、JSON 知識は不要。 +- **「チームや家族の OpenClaw を管理している。」** —— チャンネル、ランタイム、オンボーディングを 1 か所で。 +- **「高度なエージェント運用もしたい。」** —— モデル管理・可観測・メモリ・セッション・プラグイン・スキル・MCP を 1 か所に。 + ## ロードマップ 6 つのコア機能 — それぞれインフラから日常利用へ向かいます: | # | 機能 | ステータス | 実装済み | 次のステップ | |---|---|---|---|---| -| 1 | **能管理** | 利用可能 | ガイド付きウィザード、6+ LLM プロバイダー(キー検証付き)、6 チャンネル(Feishu / WeChat / Discord / Slack / Telegram / WhatsApp)、Profile 切り替え | ワンクリック環境移行([#1](https://github.com/openmaster-ai/clawmaster/issues/1))、Windows + WSL2 ファーストクラスサポート | +| 1 | **能管理** | 利用可能 | ガイド付きウィザード、6+ LLM プロバイダー(キー検証付き)、6 チャンネル(Feishu / WeChat / Discord / Slack / Telegram / WhatsApp)、Profile 切り替え | ワンクリック環境移行、Windows + WSL2 ファーストクラスサポート | | 2 | **能観測** | 利用可能 | ClawProbe ベースのダッシュボード、セッションごとのコスト・トークン追跡、ゲートウェイヘルス監視 | 履歴コスト分析、異常アラート、マルチ Profile 比較 | -| 3 | **能節約** | 開発中 | PowerMem UI + FTS5 ローカル検索、メモリワークスペース管理、markdown grep への自動フォールバック | 完全な seekdb ベクトル検索([#12](https://github.com/openmaster-ai/clawmaster/issues/12))、LLM Wiki — 時間とともに蓄積するナレッジベース([#49](https://github.com/openmaster-ai/clawmaster/issues/49)) | +| 3 | **能節約** | 開発中 | 管理対象 PowerMem ランタイム + OpenClaw ブリッジ、ワークスペースインポート、初のメモリ駆動スキル —— 詳細は[メモリハイライト](#メモリハイライト) | 完全な seekdb ハイブリッド検索、自己保守 LLM ナレッジモジュール —— 詳細は [v0.4.0 マイルストーン](https://github.com/openmaster-ai/clawmaster/milestone/1) | | 4 | **能活用** | 開発中 | PaddleOCR パイプライン(アップロード → 解析 → 構造化 Markdown)、レイアウト認識抽出 | 写真 → フラッシュカード自動生成、請求書抽出テンプレート、シナリオ優先のガイド付きワークフロー | | 5 | **能構築** | 計画中 | プラグイン / スキルのインストール・切り替え、MCP サーバー管理、スキルセキュリティ監査 | ビジュアルエージェントコンポーザー、LangChain Deep Agents 統合、対話型エージェントビルダー | | 6 | **能守護** | 計画中 | Skill Guard セキュリティスキャン(次元 / 重大度 / リスクスコア)、基本的な機能ゲーティング | API キー暗号化ボールト、Profile ごとの支出上限、チームデプロイ向け RBAC | @@ -291,8 +275,8 @@ ClawMaster を一般ユーザーにとってもっと役立つものにしたい |---|---| | [OpenClaw](https://github.com/openclaw/openclaw) | コアランタイムと設定モデル | | [ClawProbe](https://github.com/openclaw/clawprobe) | 可観測デーモン | -| [PowerMem](https://github.com/openclaw/powermem) | メモリバックエンド | -| [seekdb](https://github.com/openclaw/seekdb) | 検索・リトリーバルワークフロー | +| [PowerMem](https://github.com/oceanbase/powermem) · [TS SDK](https://github.com/ob-labs/powermem-ts) | メモリバックエンド | +| [OceanBase seekdb](https://github.com/oceanbase/seekdb) | 検索・リトリーバルワークフロー | | [Tauri](https://tauri.app) | デスクトップアプリフレームワーク | | [React](https://react.dev) | フロントエンド UI | | [Vite](https://vitejs.dev) | フロントエンドツールチェーン | diff --git a/bin/clawmaster.mjs b/bin/clawmaster.mjs index f9cefbea..314cee03 100755 --- a/bin/clawmaster.mjs +++ b/bin/clawmaster.mjs @@ -85,7 +85,7 @@ function printHelp() { ClawMaster v${pkg.version} Usage: - clawmaster serve [--host 127.0.0.1] [--port ${DEFAULT_SERVICE_PORT}] [--daemon] [--token ] [--silent] + clawmaster serve [--host 127.0.0.1] [--port ${DEFAULT_SERVICE_PORT}] [--daemon] [--token ] [--silent] [--no-gateway-watchdog] clawmaster status [--url http://127.0.0.1:${DEFAULT_SERVICE_PORT}] [--token ] clawmaster stop clawmaster doctor @@ -101,6 +101,7 @@ Commands: Notes: The service expects built backend and frontend assets. clawmaster serve protects the web UI with a service token by default. + clawmaster serve keeps the OpenClaw gateway running by default; pass --no-gateway-watchdog to opt out. clawmaster serve opens the web console in your default browser unless you pass --silent. For local source checkouts, run: npm run build:backend @@ -285,6 +286,7 @@ export function formatServeReadyMessage({ urls, token, browserRequested = false, + gatewayWatchdog = true, ready = true, }) { const lines = [ @@ -292,6 +294,7 @@ export function formatServeReadyMessage({ formatServeStatusLine('web console:', urls.url), formatServeStatusLine('bind:', `${urls.bindHost}:${urls.port}`), formatServeStatusLine('token:', token), + formatServeStatusLine('gateway:', gatewayWatchdog ? 'safeguard enabled (auto-restart)' : 'safeguard disabled'), ] if (browserRequested) { lines.push(formatServeStatusLine( @@ -474,6 +477,43 @@ async function fetchServiceInfo(baseUrl, options = {}) { throw lastError ?? new Error('unknown service probe failure') } +async function fetchGatewayStatus(baseUrl, options = {}) { + const { + token = '', + timeoutMs = 10_000, + } = options + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${baseUrl}/api/gateway/status`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + signal: controller.signal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + return await response.json() + } finally { + clearTimeout(timer) + } +} + +export function formatGatewayWatchdogStatus(watchdog) { + if (!watchdog || typeof watchdog !== 'object') { + return 'unknown' + } + if (watchdog.enabled !== true) { + return 'disabled' + } + const state = String(watchdog.state ?? 'unknown') + const restarts = Number.isFinite(Number(watchdog.restartCount)) + ? Number(watchdog.restartCount) + : 0 + const suffix = restarts === 1 ? '1 restart' : `${restarts} restarts` + return `${state}, auto-restart enabled, ${suffix}` +} + export async function waitForUrlReady(targetUrl, options = {}) { const { retries = SERVICE_READY_RETRIES, @@ -600,6 +640,15 @@ async function runStatus(args) { console.log(`openclaw: ${data?.openclaw?.installed ? data.openclaw.version || 'installed' : 'not detected'}`) console.log(`config: ${data?.openclaw?.configPath ?? 'unknown'}`) console.log(`runtime: ${data?.runtime?.mode ?? 'unknown'}`) + try { + const gateway = await fetchGatewayStatus(baseUrl, { token }) + if (typeof gateway?.running === 'boolean') { + console.log(`gateway: ${gateway.running ? 'running' : 'stopped'}${gateway.port ? ` on port ${gateway.port}` : ''}`) + console.log(formatServeStatusLine('safeguard:', formatGatewayWatchdogStatus(gateway.watchdog))) + } + } catch { + // Older services do not expose gateway status; keep status compatible. + } } catch (error) { const message = error instanceof Error ? error.message : String(error) console.error(`ClawMaster service is not reachable at ${baseUrl}: ${message}`) @@ -643,6 +692,7 @@ export function buildServiceSpawnOptions({ host, port, token, + gatewayWatchdog = true, stdoutLog, stderrLog, workingDir = process.cwd(), @@ -661,6 +711,7 @@ export function buildServiceSpawnOptions({ BACKEND_PORT: port, CLAWMASTER_FRONTEND_DIST: assets.frontendDist, CLAWMASTER_SERVICE_TOKEN: token, + CLAWMASTER_GATEWAY_WATCHDOG: gatewayWatchdog ? '1' : '0', } if (homeContext.platform === 'win32' && homeContext.overrideActive) { env.HOME = homeContext.homeDir @@ -685,6 +736,7 @@ async function runServe(args) { const port = getBackendPort(args) const daemon = hasFlag(args, 'daemon') const silent = isServeSilent(args) + const gatewayWatchdog = !hasFlag(args, 'no-gateway-watchdog') const token = getServiceToken(args) const assets = resolveServiceAssets() const urls = resolveServiceUrls(host, port) @@ -722,6 +774,7 @@ async function runServe(args) { host, port, token, + gatewayWatchdog, stdoutLog, stderrLog, }) @@ -785,6 +838,7 @@ async function runServe(args) { urls, token, browserRequested: !silent, + gatewayWatchdog, ready: true, })) if (!silent) { @@ -820,6 +874,7 @@ async function runServe(args) { urls, token, browserRequested: !silent, + gatewayWatchdog, ready: false, })) if (!silent) { diff --git a/bin/clawmaster.test.mjs b/bin/clawmaster.test.mjs index f47786e6..028d379c 100644 --- a/bin/clawmaster.test.mjs +++ b/bin/clawmaster.test.mjs @@ -159,6 +159,7 @@ test('formatServeReadyMessage clearly reports the console and bind addresses', ( assert.match(message, /web console:\s+http:\/\/127\.0\.0\.1:16223/) assert.match(message, /bind:\s+0\.0\.0\.0:16223/) assert.match(message, /token:\s+secret-token/) + assert.match(message, /gateway:\s+safeguard enabled \(auto-restart\)/) assert.match(message, /browser:\s+opening the default browser/) assert.match(message, /next:\s+clawmaster status \| clawmaster stop/) }) @@ -174,10 +175,31 @@ test('formatServeReadyMessage describes deferred foreground browser launch witho assert.match(message, /ClawMaster service is starting\./) assert.match(message, /browser:\s+opening when the web console becomes reachable/) + assert.match(message, /gateway:\s+safeguard enabled \(auto-restart\)/) assert.match(message, /next:\s+Ctrl\+C to stop/) assert.doesNotMatch(message, /skipped/i) }) +test('formatServeReadyMessage reports when gateway safeguard is disabled', () => { + const message = cliModule.formatServeReadyMessage({ + daemon: false, + urls: cliModule.resolveServiceUrls('127.0.0.1', '16223'), + token: 'secret-token', + gatewayWatchdog: false, + }) + + assert.match(message, /gateway:\s+safeguard disabled/) +}) + +test('formatGatewayWatchdogStatus summarizes watchdog state for CLI status', () => { + assert.equal(cliModule.formatGatewayWatchdogStatus(null), 'unknown') + assert.equal(cliModule.formatGatewayWatchdogStatus({ enabled: false }), 'disabled') + assert.equal( + cliModule.formatGatewayWatchdogStatus({ enabled: true, state: 'healthy', restartCount: 2 }), + 'healthy, auto-restart enabled, 2 restarts', + ) +}) + test('resolveServiceStatePaths prefers an explicit Windows HOME override', () => { assert.deepEqual( cliModule.resolveServiceStatePaths({ @@ -375,6 +397,7 @@ test('buildServiceSpawnOptions preserves the caller working directory', () => { assert.equal(options.cwd, workingDir) assert.equal(options.env.CLAWMASTER_FRONTEND_DIST, '/tmp/frontend-dist') assert.equal(options.env.CLAWMASTER_SERVICE_TOKEN, 'secret-token') + assert.equal(options.env.CLAWMASTER_GATEWAY_WATCHDOG, '1') assert.equal(options.env.BACKEND_HOST, '127.0.0.1') assert.equal(options.env.BACKEND_PORT, '16223') assert.equal(options.windowsHide, true) @@ -384,6 +407,21 @@ test('buildServiceSpawnOptions preserves the caller working directory', () => { rmSync(tempHome, { recursive: true, force: true }) }) +test('buildServiceSpawnOptions can disable the gateway watchdog for service env', () => { + const options = cliModule.buildServiceSpawnOptions({ + assets: { frontendDist: '/tmp/frontend-dist' }, + daemon: false, + host: '127.0.0.1', + port: '16223', + token: 'secret-token', + gatewayWatchdog: false, + stdoutLog: 'ignored.stdout.log', + stderrLog: 'ignored.stderr.log', + }) + + assert.equal(options.env.CLAWMASTER_GATEWAY_WATCHDOG, '0') +}) + test('buildServiceSpawnOptions propagates a Windows HOME override to backend env', () => { const options = cliModule.buildServiceSpawnOptions({ assets: { frontendDist: 'C:\\portable-home\\frontend-dist' }, @@ -408,13 +446,69 @@ test('help documents serve silent mode and browser auto-open', async () => { const tempHome = createTempHome() try { const { stdout } = await runCli(['--help'], tempHome) - assert.match(stdout, /serve \[--host 127\.0\.0\.1] \[--port 16223] \[--daemon] \[--token ] \[--silent]/) + assert.match(stdout, /serve \[--host 127\.0\.0\.1] \[--port 16223] \[--daemon] \[--token ] \[--silent] \[--no-gateway-watchdog]/) + assert.match(stdout, /keeps the OpenClaw gateway running by default/i) assert.match(stdout, /opens the web console in your default browser unless you pass --silent/i) } finally { rmSync(tempHome, { recursive: true, force: true }) } }) +test('status reports gateway safeguard state when the service exposes it', async () => { + const tempHome = createTempHome() + const token = 'local-service-token' + const server = createServer((req, res) => { + if (req.headers.authorization !== `Bearer ${token}`) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'unauthorized' })) + return + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + if (req.url?.startsWith('/api/gateway/status')) { + res.end(JSON.stringify({ + running: true, + port: 18789, + watchdog: { + enabled: true, + state: 'healthy', + restartCount: 2, + }, + })) + return + } + res.end(JSON.stringify({ + openclaw: { installed: true, version: '1.2.3', configPath: '/tmp/openclaw.json' }, + runtime: { mode: 'native' }, + })) + }) + + try { + await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', (error) => (error ? reject(error) : resolve())) + }) + const address = server.address() + assert.ok(address && typeof address === 'object') + const url = `http://127.0.0.1:${address.port}` + + writeServiceState(tempHome, { + pid: process.pid, + url, + token, + startedAt: '2026-04-11T00:00:00.000Z', + }) + + const { stdout } = await runCli(['status'], tempHome) + + assert.match(stdout, /gateway:\s+running on port 18789/) + assert.match(stdout, /safeguard:\s+healthy, auto-restart enabled, 2 restarts/) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + rmSync(tempHome, { recursive: true, force: true }) + } +}) + test('status --url does not reuse local daemon token or metadata for a different target', async () => { const tempHome = createTempHome() const requests = [] diff --git a/bundled-skills/clawprobe-cost-digest/scripts/common.mjs b/bundled-skills/clawprobe-cost-digest/scripts/common.mjs index 54d1d03f..8572a47f 100644 --- a/bundled-skills/clawprobe-cost-digest/scripts/common.mjs +++ b/bundled-skills/clawprobe-cost-digest/scripts/common.mjs @@ -414,6 +414,7 @@ function getGlobalNpmRoot() { encoding: 'utf8', env: process.env, windowsHide: true, + shell: process.platform === 'win32', }).trim() } catch { return null diff --git a/bundled-skills/package-download-tracker/SKILL.md b/bundled-skills/package-download-tracker/SKILL.md new file mode 100644 index 00000000..99d02c32 --- /dev/null +++ b/bundled-skills/package-download-tracker/SKILL.md @@ -0,0 +1,78 @@ +--- +name: package-download-tracker +description: Track npm and PyPI package downloads by week or month, use stored history observations, and save compact trend analysis for future package adoption questions. Use when the user asks for npm downloads, PyPI downloads, package popularity, package growth, or recurring package download trend tracking. +metadata: + openclaw: + requires: + anyBins: + - node +--- + +# package-download-tracker + +Use this skill when OpenClaw needs current npm or PyPI package download trends, especially for recurring weekly or monthly analysis. + +This skill fetches registry download data, keeps a local normalized current window, and can use saved observations as history so follow-up trend explanations do not need broad historical registry queries. + +## Critical Rule + +This skill is guidance plus runnable scripts, not a callable tool name. + +- Do not call `package-download-tracker` as if it were a built-in tool. +- First use `read` to load this `SKILL.md`. +- Then use `exec` to run the bundled Node script with `node`. +- When the user asks for trend analysis over time, use `--load-memory --save-memory` so previous observations are recalled and refreshed. +- Treat PowerMem recall/save warnings, cache details, script commands, and stored-observation mechanics as internal diagnostics. Do not surface them to the user unless they explicitly ask about implementation mechanics. + +## Data Sources + +- npm: `https://api.npmjs.org/downloads/range//` +- PyPI: `https://pypistats.org/api/packages//overall` and `recent` + +## Script Directory + +Treat the directory containing this `SKILL.md` as `SKILL_DIR`, then use: + +| Script | Purpose | +|---|---| +| `scripts/track-downloads.mjs` | Fetch package downloads, analyze trends from stored observations, and optionally save a new observation | + +## Commands + +```bash +# Weekly npm package tracker with memory recall and save +node ${SKILL_DIR}/scripts/track-downloads.mjs --registry npm --packages clawmaster,powermem --period week --load-memory --save-memory --summary + +# Monthly PyPI tracker +node ${SKILL_DIR}/scripts/track-downloads.mjs --registry pypi --package powermem --period month --load-memory --save-memory + +# Force a fresh registry request instead of using today's cache +node ${SKILL_DIR}/scripts/track-downloads.mjs --registry npm --package @types/node --period week --refresh +``` + +## Query Flags + +| Flag | Meaning | +|---|---| +| `--registry ` | Required registry source | +| `--package ` | Package to track; repeatable | +| `--packages ` | Comma-separated packages to track | +| `--period ` | Tracking window | +| `--summary` | Print a compact markdown summary instead of JSON | +| `--refresh` | Ignore the local cache for the current window | +| `--load-memory` | Search PowerMem for previous snapshots before registry fetches | +| `--save-memory` | Save the current compact analysis snapshot through `openclaw ltm add` | +| `--cache-path ` | Override the cache directory | +| `--history-limit ` | Number of historical observations to keep in trend analysis; default is `6` | + +## Response Expectations + +When you use this skill: + +1. Put at least two period columns in the user-facing table: `Current week` and `Previous week` for weekly tracking, or `Current month` and `Previous month` for monthly tracking. +2. Base trend analysis on the history observations returned by the script when available. +3. Prefer stored history over repeated broad registry queries just to rebuild old context. +4. Mention npm/PyPI API or data-quality warnings only when they affect the numbers. +5. Keep the table internally consistent: if the previous-period column is `n/a`, `-`, or otherwise unavailable, the trend/change cell must also say there is no previous-period data. Only show a percentage change when the previous-period column shows the numeric baseline used for that calculation. +6. Keep PowerMem recall/save diagnostics, cache details, script commands, and stored-observation mechanics out of the user-facing summary. Describe them only as current data and prior observations. +7. Do not invent download numbers. diff --git a/bundled-skills/package-download-tracker/_meta.json b/bundled-skills/package-download-tracker/_meta.json new file mode 100644 index 00000000..96c9c496 --- /dev/null +++ b/bundled-skills/package-download-tracker/_meta.json @@ -0,0 +1,5 @@ +{ + "slug": "package-download-tracker", + "version": "1.0.0", + "bundled": true +} diff --git a/bundled-skills/package-download-tracker/scripts/common.mjs b/bundled-skills/package-download-tracker/scripts/common.mjs new file mode 100644 index 00000000..a1242fae --- /dev/null +++ b/bundled-skills/package-download-tracker/scripts/common.mjs @@ -0,0 +1,756 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +export const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.openclaw', 'cache', 'package-download-tracker') +export const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000 +export const SKILL_MARKER = 'package-download-tracker' + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }) +} + +function normalizeToken(value) { + return String(value ?? '').trim().toLowerCase() +} + +function parsePackageList(value) { + return String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function canonicalPypiPackageName(value) { + return String(value ?? '').trim().toLowerCase().replace(/[-_.]+/g, '-') +} + +export function normalizeRegistry(value) { + const normalized = normalizeToken(value) + if (normalized !== 'npm' && normalized !== 'pypi') { + throw new Error(`Unsupported --registry value: ${value}`) + } + return normalized +} + +export function normalizePeriod(value) { + const normalized = normalizeToken(value || 'week') + if (normalized !== 'week' && normalized !== 'month') { + throw new Error(`Unsupported --period value: ${value}`) + } + return normalized +} + +export function normalizePackageName(registry, value) { + const name = String(value ?? '').trim() + if (!name) throw new Error('Package name is required') + + if (registry === 'npm') { + if (/\s/.test(name)) throw new Error(`Invalid npm package name: ${name}`) + if (name.startsWith('@')) { + if (!/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._~-]*$/i.test(name)) { + throw new Error(`Invalid npm scoped package name: ${name}`) + } + return name + } + if (!/^[a-z0-9][a-z0-9._~-]*$/i.test(name)) { + throw new Error(`Invalid npm package name: ${name}`) + } + return name + } + + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) { + throw new Error(`Invalid PyPI package name: ${name}`) + } + return canonicalPypiPackageName(name) +} + +export function parseArgs(argv) { + const options = { + registry: '', + packages: [], + period: 'week', + summary: false, + refresh: false, + saveMemory: false, + loadMemory: false, + cachePath: DEFAULT_CACHE_DIR, + maxAgeMs: DEFAULT_MAX_AGE_MS, + historyLimit: 6, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const next = argv[index + 1] + + if (arg === '--registry' && next) { + options.registry = next + index += 1 + } else if (arg === '--package' && next) { + options.packages.push(next) + index += 1 + } else if (arg === '--packages' && next) { + options.packages.push(...parsePackageList(next)) + index += 1 + } else if (arg === '--period' && next) { + options.period = next + index += 1 + } else if (arg === '--summary') { + options.summary = true + } else if (arg === '--refresh') { + options.refresh = true + } else if (arg === '--save-memory') { + options.saveMemory = true + } else if (arg === '--load-memory') { + options.loadMemory = true + } else if (arg === '--cache-path' && next) { + options.cachePath = next + index += 1 + } else if (arg === '--max-age-ms' && next) { + const parsed = Number(next) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid --max-age-ms value: ${next}`) + } + options.maxAgeMs = parsed + index += 1 + } else if (arg === '--history-limit' && next) { + const parsed = Number(next) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`Invalid --history-limit value: ${next}`) + } + options.historyLimit = parsed + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + + const registry = normalizeRegistry(options.registry) + const period = normalizePeriod(options.period) + const packages = [...new Set(options.packages.map((pkg) => normalizePackageName(registry, pkg)))] + if (packages.length === 0) { + throw new Error('At least one --package or --packages value is required') + } + + return { + ...options, + registry, + period, + packages, + } +} + +function periodDays(period) { + return period === 'month' ? 30 : 7 +} + +function cacheFilePath(cacheDir, registry, packageName, period) { + const key = Buffer.from(`${registry}:${packageName}:${period}`).toString('base64url') + return path.join(cacheDir, `${key}.json`) +} + +function readCache(filePath, maxAgeMs, now = Date.now()) { + try { + const stat = fs.statSync(filePath) + if (now - stat.mtimeMs > maxAgeMs) return null + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } catch { + return null + } +} + +function writeCache(filePath, payload) { + ensureDir(path.dirname(filePath)) + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') +} + +async function fetchJson(url, fetchImpl = fetch) { + const response = await fetchImpl(url, { + headers: { + Accept: 'application/json', + 'User-Agent': 'ClawMaster package-download-tracker', + }, + }) + if (!response.ok) { + throw new Error(`Registry request failed (${response.status}) for ${url}`) + } + return response.json() +} + +function normalizeDailyRows(rows) { + return rows + .map((row) => ({ + date: String(row.date ?? row.day ?? '').slice(0, 10), + downloads: Number(row.downloads ?? row.count ?? 0), + })) + .filter((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.date) && Number.isFinite(row.downloads)) + .sort((a, b) => a.date.localeCompare(b.date)) +} + +function sumDownloads(rows) { + return rows.reduce((sum, row) => sum + row.downloads, 0) +} + +function compareTotals(current, previous) { + if (!Number.isFinite(previous) || previous <= 0) { + return { previous: previous ?? null, absolute: null, percent: null } + } + const absolute = current - previous + return { + previous, + absolute, + percent: absolute / previous, + } +} + +function formatPercent(value) { + return `${(value * 100).toFixed(1)}%` +} + +function formatDownloads(value) { + if (value === null || value === undefined) return 'n/a' + return Number.isFinite(Number(value)) ? Number(value).toLocaleString('en-US') : 'n/a' +} + +function hasDownloads(value) { + if (value === null || value === undefined) return false + return Number.isFinite(Number(value)) +} + +function displayTrendText(result) { + return hasDownloads(result.periodColumns?.[1]?.downloads) + ? result.trend.text + : `No previous ${periodNoun(result.period)} data available.` +} + +function periodNoun(period) { + return period === 'month' ? 'month' : 'week' +} + +function periodLabel(period) { + return period === 'month' ? 'month' : 'week' +} + +function trendDirection(delta) { + return delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat' +} + +function compactDate(value) { + const text = String(value ?? '') + return text.length >= 10 ? text.slice(0, 10) : 'prior run' +} + +function formatHistory(history) { + return history + .map((point) => `${compactDate(point.fetchedAt)} ${point.downloads.toLocaleString('en-US')}`) + .join(' -> ') +} + +function historyDateKey(value, fallback) { + const text = String(value ?? '') + return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : `unknown:${fallback}` +} + +function buildHistory(snapshots, current, fetchedAt, limit) { + const byDate = new Map() + snapshots.forEach((snapshot, index) => { + const downloads = Number(snapshot.totalDownloads) + if (!Number.isFinite(downloads)) return + const observedAt = String(snapshot.fetchedAt ?? '') + const row = { + fetchedAt: observedAt, + downloads, + source: 'memory', + } + const key = historyDateKey(observedAt, index) + const existing = byDate.get(key) + if (!existing || snapshotTimestamp(row) >= snapshotTimestamp(existing)) { + byDate.set(key, row) + } + }) + const rows = [...byDate.values()] + rows.sort((a, b) => snapshotTimestamp(a) - snapshotTimestamp(b)) + const selected = rows.slice(Math.max(0, rows.length - Math.max(0, limit - 1))) + const currentRow = { + fetchedAt, + downloads: current.totals.downloads, + source: current.fromCache ? 'cache' : 'registry', + } + const currentKey = historyDateKey(currentRow.fetchedAt, 'current') + const existingCurrentIndex = selected.findIndex((row, index) => historyDateKey(row.fetchedAt, index) === currentKey) + if (existingCurrentIndex >= 0) { + selected[existingCurrentIndex] = currentRow + } else { + selected.push(currentRow) + } + return selected +} + +function buildTrend(history, comparison) { + if (history.length >= 2) { + const first = history[0] + const previous = history[history.length - 2] + const current = history[history.length - 1] + const delta = current.downloads - previous.downloads + const direction = trendDirection(delta) + const periodDelta = previous.downloads > 0 ? delta / previous.downloads : null + const totalDelta = current.downloads - first.downloads + const totalDirection = trendDirection(totalDelta) + const totalPercent = first.downloads > 0 ? totalDelta / first.downloads : null + const text = periodDelta === null + ? `${direction} ${delta.toLocaleString('en-US')} downloads versus previous observation` + : `${direction} ${formatPercent(periodDelta)} versus previous observation (${compactDate(previous.fetchedAt)})` + return { + direction, + basis: 'history', + text, + points: history.length, + previousDelta: { + absolute: delta, + percent: periodDelta, + }, + overallDelta: { + direction: totalDirection, + absolute: totalDelta, + percent: totalPercent, + text: totalPercent === null + ? `${totalDirection} ${totalDelta.toLocaleString('en-US')} downloads across ${history.length} observations` + : `${totalDirection} ${formatPercent(totalPercent)} across ${history.length} observations`, + }, + } + } + + if (comparison.percent !== null) { + const direction = comparison.absolute > 0 ? 'up' : comparison.absolute < 0 ? 'down' : 'flat' + return { + direction, + basis: 'registry-window', + text: `${direction} ${formatPercent(comparison.percent)} versus previous ${periodLabel(comparison.period ?? 'week')}`, + points: 1, + } + } + + return { + direction: 'unknown', + basis: 'current-window', + text: `No previous ${periodLabel(comparison.period ?? 'period')} data available.`, + points: 1, + } +} + +function buildPeriodColumns(period, history, comparison, currentDownloads) { + const previousHistory = history.length >= 2 ? history[history.length - 2] : null + const hasHistoryPrevious = Number.isFinite(Number(previousHistory?.downloads)) + const hasRegistryPrevious = Number.isFinite(comparison.previous) && ( + Number.isFinite(comparison.percent) || + Number.isFinite(comparison.absolute) || + comparison.previous > 0 + ) + const previousDownloads = hasHistoryPrevious + ? Number(previousHistory.downloads) + : hasRegistryPrevious + ? comparison.previous + : null + const previousSource = hasHistoryPrevious + ? 'history' + : hasRegistryPrevious + ? 'registry-window' + : 'unavailable' + + return [ + { + key: 'current', + label: `Current ${periodNoun(period)}`, + downloads: currentDownloads, + source: 'current', + }, + { + key: 'previous', + label: `Previous ${periodNoun(period)}`, + downloads: previousDownloads, + source: previousSource, + }, + ] +} + +function normalizeNpmPayload(packageName, period, raw) { + const daily = normalizeDailyRows(Array.isArray(raw.downloads) ? raw.downloads : []) + const current = daily.slice(-periodDays(period)) + const previous = daily.slice(Math.max(0, daily.length - periodDays(period) * 2), Math.max(0, daily.length - periodDays(period))) + const total = sumDownloads(current) + return { + source: { + kind: 'npm', + url: `https://api.npmjs.org/downloads/range/last-${period}/${encodeURIComponent(packageName)}`, + }, + daily: current, + totals: { + downloads: total, + days: current.length, + }, + comparison: compareTotals(total, previous.length === periodDays(period) ? sumDownloads(previous) : null), + warnings: current.length === 0 ? ['npm returned no daily download rows'] : [], + } +} + +function normalizePypiPayload(packageName, period, overallRaw, recentRaw) { + const allRows = normalizeDailyRows(Array.isArray(overallRaw.data) ? overallRaw.data : []) + const current = allRows.slice(-periodDays(period)) + const previous = allRows.slice(Math.max(0, allRows.length - periodDays(period) * 2), Math.max(0, allRows.length - periodDays(period))) + const summed = sumDownloads(current) + const recent = isRecord(recentRaw?.data) ? recentRaw.data : {} + const recentKey = period === 'month' ? 'last_month' : 'last_week' + const recentTotal = Number(recent[recentKey]) + const total = Number.isFinite(recentTotal) && recentTotal > 0 ? recentTotal : summed + const warnings = [] + if (current.length === 0) warnings.push('PyPIStats returned no daily download rows') + if (Number.isFinite(recentTotal) && summed > 0 && recentTotal !== summed) { + warnings.push('PyPIStats recent aggregate differs from summed daily rows; using recent aggregate for total') + } + + return { + source: { + kind: 'pypistats', + urls: [ + `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/overall?mirrors=false`, + `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/recent?period=${period}`, + ], + }, + daily: current, + totals: { + downloads: total, + days: current.length, + }, + comparison: compareTotals(total, previous.length === periodDays(period) ? sumDownloads(previous) : null), + warnings, + } +} + +export function buildMemorySearchQuery(registry, packageName, period) { + return `${SKILL_MARKER} registry:${registry} package:${packageName} period:${period}` +} + +function canonicalPackageName(registry, value) { + const name = String(value ?? '').trim() + return registry === 'npm' ? name : canonicalPypiPackageName(name) +} + +function snapshotsMatchRequest(snapshot, registry, packageName, period) { + if (!isRecord(snapshot)) return false + return ( + normalizeToken(snapshot.registry) === registry && + canonicalPackageName(registry, snapshot.packageName) === canonicalPackageName(registry, packageName) && + normalizeToken(snapshot.period) === period + ) +} + +function snapshotTimestamp(snapshot) { + const time = Date.parse(String(snapshot?.fetchedAt ?? '')) + return Number.isFinite(time) ? time : 0 +} + +function extractSnapshotFromContent(content) { + const text = String(content ?? '') + const marker = 'snapshot-json:' + const markerIndex = text.indexOf(marker) + if (markerIndex < 0) return null + const jsonStart = text.indexOf('{', markerIndex + marker.length) + if (jsonStart < 0) return null + const jsonEnd = findJsonEnd(text, jsonStart) + if (jsonEnd === null) return null + try { + const parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1)) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +function parseJsonLoose(raw) { + const text = String(raw ?? '').trim() + if (!text) return null + try { + return JSON.parse(text) + } catch { + for (let start = 0; start < text.length; start += 1) { + const ch = text[start] + if (ch !== '[' && ch !== '{') continue + const end = findJsonEnd(text, start) + if (end === null) continue + try { + return JSON.parse(text.slice(start, end + 1)) + } catch { + // Keep scanning in case log lines precede the JSON payload. + } + } + throw new Error('Invalid JSON from PowerMem search') + } +} + +function findJsonEnd(text, start) { + const first = text[start] + const stack = [first === '[' ? ']' : '}'] + let inString = false + let escaped = false + + for (let index = start + 1; index < text.length; index += 1) { + const ch = text[index] + if (inString) { + if (escaped) { + escaped = false + } else if (ch === '\\') { + escaped = true + } else if (ch === '"') { + inString = false + } + continue + } + if (ch === '"') { + inString = true + } else if (ch === '[') { + stack.push(']') + } else if (ch === '{') { + stack.push('}') + } else if (ch === ']' || ch === '}') { + if (stack.pop() !== ch) return null + if (stack.length === 0) return index + } + } + return null +} + +function memoryRowContent(row) { + if (typeof row === 'string') return row + if (!isRecord(row)) return '' + if (typeof row.content === 'string') return row.content + if (typeof row.text === 'string') return row.text + if (typeof row.memory === 'string') return row.memory + if (isRecord(row.memory)) { + if (typeof row.memory.content === 'string') return row.memory.content + if (typeof row.memory.text === 'string') return row.memory.text + } + return '' +} + +function normalizeMemoryHits(raw, { registry, packageName, period }) { + const rows = Array.isArray(raw) + ? raw + : Array.isArray(raw?.hits) + ? raw.hits + : Array.isArray(raw?.memories) + ? raw.memories + : Array.isArray(raw?.results) + ? raw.results + : [] + const extracted = rows + .map((row) => extractSnapshotFromContent(memoryRowContent(row))) + .filter((snapshot) => isRecord(snapshot) && Number.isFinite(Number(snapshot.totalDownloads))) + const snapshots = extracted + .filter((snapshot) => snapshotsMatchRequest(snapshot, registry, packageName, period)) + .sort((a, b) => snapshotTimestamp(b) - snapshotTimestamp(a)) + return { + snapshots, + ignored: extracted.length - snapshots.length, + } +} + +export function defaultRunOpenclaw(args) { + const result = spawnSync('openclaw', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }) + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim() + if (result.error) throw result.error + if ((result.status ?? 0) !== 0) { + throw new Error(output || `openclaw exited with status ${result.status}`) + } + return output +} + +export function loadPreviousSnapshots({ registry, packageName, period, runOpenclaw = defaultRunOpenclaw }) { + const query = buildMemorySearchQuery(registry, packageName, period) + try { + const raw = runOpenclaw(['ltm', 'search', '--json', '--query', query]) + const normalized = normalizeMemoryHits(parseJsonLoose(raw), { registry, packageName, period }) + return { + query, + snapshots: normalized.snapshots, + diagnostics: { + ignored: normalized.ignored, + warning: null, + }, + } + } catch (error) { + return { + query, + snapshots: [], + diagnostics: { + ignored: 0, + warning: `PowerMem recall unavailable: ${error instanceof Error ? error.message : String(error)}`, + }, + } + } +} + +export function buildMemoryContent(result) { + const snapshot = { + registry: result.registry, + packageName: result.packageName, + period: result.period, + fetchedAt: result.fetchedAt, + totalDownloads: result.totals.downloads, + trend: result.trend, + warnings: result.warnings, + } + return [ + buildMemorySearchQuery(result.registry, result.packageName, result.period), + `summary: ${result.packageName} had ${result.totals.downloads.toLocaleString('en-US')} ${result.period} downloads; ${result.trend.text}`, + `snapshot-json: ${JSON.stringify(snapshot)}`, + ].join('\n') +} + +export function saveSnapshotToMemory(result, runOpenclaw = defaultRunOpenclaw) { + try { + runOpenclaw(['ltm', 'add', '--json', '--', buildMemoryContent(result)]) + return null + } catch (error) { + return `PowerMem save unavailable: ${error instanceof Error ? error.message : String(error)}` + } +} + +async function fetchCurrentWindow({ registry, packageName, period, fetchImpl }) { + if (registry === 'npm') { + const url = `https://api.npmjs.org/downloads/range/last-${period}/${encodeURIComponent(packageName)}` + return normalizeNpmPayload(packageName, period, await fetchJson(url, fetchImpl)) + } + + const overallUrl = `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/overall?mirrors=false` + const recentUrl = `https://pypistats.org/api/packages/${encodeURIComponent(packageName)}/recent?period=${period}` + const [overall, recent] = await Promise.all([ + fetchJson(overallUrl, fetchImpl), + fetchJson(recentUrl, fetchImpl), + ]) + return normalizePypiPayload(packageName, period, overall, recent) +} + +export async function analyzePackage({ + registry, + packageName, + period, + cachePath = DEFAULT_CACHE_DIR, + maxAgeMs = DEFAULT_MAX_AGE_MS, + refresh = false, + loadMemory = false, + saveMemory = false, + historyLimit = 6, + fetchImpl = fetch, + runOpenclaw = defaultRunOpenclaw, + now = () => new Date(), +}) { + const fetchedAt = now().toISOString() + const memory = loadMemory ? loadPreviousSnapshots({ registry, packageName, period, runOpenclaw }) : null + + const filePath = cacheFilePath(cachePath, registry, packageName, period) + const cached = refresh || saveMemory ? null : readCache(filePath, maxAgeMs, now().getTime()) + const current = cached ?? await fetchCurrentWindow({ registry, packageName, period, fetchImpl }) + current.fromCache = Boolean(cached) + if (!cached) { + writeCache(filePath, current) + } + + const priorSnapshot = memory?.snapshots?.[0] ?? null + const history = buildHistory(memory?.snapshots ?? [], current, fetchedAt, historyLimit) + const trend = buildTrend(history, { ...current.comparison, period }) + const periodColumns = buildPeriodColumns(period, history, current.comparison, current.totals.downloads) + const result = { + registry, + packageName, + period, + fetchedAt, + source: current.source, + cache: { + fromCache: Boolean(cached), + path: filePath, + }, + totals: current.totals, + daily: current.daily, + comparison: current.comparison, + priorMemory: priorSnapshot, + history, + periodColumns, + trend, + warnings: [...current.warnings], + diagnostics: { + memory: { + query: memory?.query ?? null, + snapshotsLoaded: memory?.snapshots?.length ?? 0, + ignoredSnapshots: memory?.diagnostics?.ignored ?? 0, + warning: memory?.diagnostics?.warning ?? null, + saveWarning: null, + }, + }, + } + + if (saveMemory) { + const warning = saveSnapshotToMemory(result, runOpenclaw) + if (warning) result.diagnostics.memory.saveWarning = warning + } + + return result +} + +export async function trackDownloads(options) { + const results = [] + for (const packageName of options.packages) { + results.push(await analyzePackage({ ...options, packageName })) + } + return { + registry: options.registry, + packages: options.packages, + period: options.period, + fetchedAt: new Date().toISOString(), + results, + warnings: results.flatMap((result) => result.warnings.map((warning) => `${result.packageName}: ${warning}`)), + } +} + +export function summarizeResults(payload) { + const lines = [ + `# Package Download Tracker (${payload.registry}, ${payload.period})`, + '', + `| Package | ${payload.results[0]?.periodColumns?.[0]?.label ?? `Current ${periodNoun(payload.period)}`} | ${payload.results[0]?.periodColumns?.[1]?.label ?? `Previous ${periodNoun(payload.period)}`} | Trend |`, + '|---|---:|---:|---|', + ] + + for (const result of payload.results) { + const previousDownloads = result.periodColumns?.[1]?.downloads + lines.push(`| ${result.packageName} | ${formatDownloads(result.periodColumns?.[0]?.downloads)} | ${formatDownloads(previousDownloads)} | ${displayTrendText(result)} |`) + } + + lines.push('') + + for (const result of payload.results) { + lines.push(`## ${result.packageName}`) + if (result.history.length > 1) { + lines.push(`- Observations: ${result.history.length} periods (${formatHistory(result.history)})`) + lines.push(`- Recent trend: ${displayTrendText(result)}`) + if (result.trend.overallDelta?.text) { + lines.push(`- Longer trend: ${result.trend.overallDelta.text}`) + } + } else { + lines.push(`- Trend: ${displayTrendText(result)}`) + } + for (const warning of result.warnings) { + lines.push(`- Warning: ${warning}`) + } + lines.push('') + } + + return lines.join('\n').trimEnd() +} diff --git a/bundled-skills/package-download-tracker/scripts/track-downloads.mjs b/bundled-skills/package-download-tracker/scripts/track-downloads.mjs new file mode 100644 index 00000000..e1a8eb83 --- /dev/null +++ b/bundled-skills/package-download-tracker/scripts/track-downloads.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { + parseArgs, + summarizeResults, + trackDownloads, +} from './common.mjs' + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const payload = await trackDownloads(options) + if (options.summary) { + process.stdout.write(`${summarizeResults(payload)}\n`) + return + } + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`) +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +}) diff --git a/package-lock.json b/package-lock.json index 10a24328..8e769a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "packages/*" ], "dependencies": { - "@langchain/core": "^1.1.38", + "@langchain/core": "^1.1.45", "@sinclair/typebox": "^0.34.0", "@tauri-apps/api": "^2.10.1", "cors": "^2.8.5", @@ -1078,9 +1078,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.1.39", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.39.tgz", - "integrity": "sha512-DP9c7TREy6iA7HnywstmUAsNyJNYTFpRg2yBfQ+6H0l1HnvQzei9GsQ36GeOLxgRaD3vm9K8urCcawSC7yQpCw==", + "version": "1.1.45", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz", + "integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -1092,7 +1092,6 @@ "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", - "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "engines": { @@ -4163,13 +4162,12 @@ } }, "node_modules/langsmith": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.18.tgz", - "integrity": "sha512-3zuZUWffTHQ+73EAwnodADtf534VNEZUpXr9jC12qyG8/IQuJET7PRsCpTb9wX2lmBspakwLUpqpj3tNm/0bVA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.3.tgz", + "integrity": "sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA==", "license": "MIT", "dependencies": { - "p-queue": "6.6.2", - "uuid": "10.0.0" + "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", @@ -4196,19 +4194,6 @@ } } }, - "node_modules/langsmith/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -4886,9 +4871,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -6829,19 +6814,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7734,7 +7706,7 @@ "name": "@openclaw-manager/backend", "version": "0.3.1", "dependencies": { - "@langchain/core": "^1.1.38", + "@langchain/core": "^1.1.45", "cors": "^2.8.5", "express": "^4.21.0", "powermem": "^0.1.0", @@ -7773,7 +7745,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "jsdom": "^29.0.1", - "postcss": "^8.4.49", + "postcss": "^8.5.14", "tailwindcss": "^3.4.17", "typescript": "~5.6.3", "vite": "^6.0.5", diff --git a/package.json b/package.json index 39c1c4e7..f310fabf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawmaster", - "version": "0.3.1", + "version": "0.4.0", "description": "龙虾管理大师——OpenClaw 生态的六边形战士", "bin": { "clawmaster": "bin/clawmaster.mjs" @@ -11,6 +11,7 @@ "bundled-skills/clawprobe-cost-digest/", "bundled-skills/ernie-image/", "bundled-skills/models-dev/", + "bundled-skills/package-download-tracker/", "bundled-skills/paddleocr-doc-parsing/", "plugins/memory-clawmaster-powermem/", "plugins/openclaw-ernie-image/", @@ -37,7 +38,7 @@ "tauri:dev": "tauri dev", "tauri:build": "tauri build", "prepack": "npm run build:backend && npm run build", - "test:cli": "node --test bin/*.test.mjs scripts/*.test.mjs tests/desktop/harness.unit.test.mjs", + "test:cli": "node --test bin/*.test.mjs tests/desktop/harness.unit.test.mjs", "test:plugins": "npx tsx --test plugins/memory-clawmaster-powermem/*.test.ts", "test:desktop": "node --test tests/desktop/smoke.test.mjs", "test:wizard-smoke": "node tests/install/wizard-install-smoke.mjs", @@ -57,12 +58,15 @@ "selenium-webdriver": "^4.34.0" }, "dependencies": { - "@langchain/core": "^1.1.38", + "@langchain/core": "^1.1.45", "@sinclair/typebox": "^0.34.0", "@tauri-apps/api": "^2.10.1", "cors": "^2.8.5", "express": "^4.21.0", "powermem": "^0.1.0", "ws": "^8.18.0" + }, + "overrides": { + "langsmith": "^0.6.3" } } diff --git a/packages/backend/package.json b/packages/backend/package.json index d8d8331c..42ea9f70 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw-manager/backend", - "version": "0.3.1", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -10,7 +10,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@langchain/core": "^1.1.38", + "@langchain/core": "^1.1.45", "cors": "^2.8.5", "express": "^4.21.0", "powermem": "^0.1.0", diff --git a/packages/backend/src/execClawprobe.ts b/packages/backend/src/execClawprobe.ts index b9e04ee3..32ec23fd 100644 --- a/packages/backend/src/execClawprobe.ts +++ b/packages/backend/src/execClawprobe.ts @@ -508,6 +508,7 @@ export async function runClawprobeJson( const out = await execFileAsync(cmd, cmdArgs, { maxBuffer: 20 * 1024 * 1024, env, + shell: process.platform === 'win32', }) stdout = String(out.stdout ?? '').trim() stderr = String(out.stderr ?? '').trim() @@ -596,6 +597,7 @@ export async function runClawprobeCommand( const out = await execFileAsync(cmd, cmdArgs, { maxBuffer: 20 * 1024 * 1024, env, + shell: process.platform === 'win32', }) return { ok: true, diff --git a/packages/backend/src/execOpenclaw.test.ts b/packages/backend/src/execOpenclaw.test.ts index d86c3755..886074df 100644 --- a/packages/backend/src/execOpenclaw.test.ts +++ b/packages/backend/src/execOpenclaw.test.ts @@ -1,8 +1,11 @@ import assert from 'node:assert/strict' +import os from 'node:os' import test from 'node:test' import { execNpmInstallGlobalFile, + getDarwinNodeCandidatePathsForTests, + getDarwinNodeHomeRootsForTests, needsShellOnWindows, resolveExecFileCommand, resolveNpmExecFileCommand, @@ -32,15 +35,21 @@ test('resolveExecFileCommand resolves clawhub to clawhub.cmd on Windows', async }) }) -test('resolveExecFileCommand keeps ollama bare on Windows (native binary)', async () => { +test('resolveExecFileCommand resolves openclaw to openclaw.cmd on Windows', async () => { await withPlatform('win32', () => { - assert.equal(resolveExecFileCommand('ollama'), 'ollama') + assert.equal(resolveExecFileCommand('openclaw'), 'openclaw.cmd') }) }) -test('resolveExecFileCommand keeps openclaw bare on Windows', async () => { +test('resolveExecFileCommand resolves clawprobe to clawprobe.cmd on Windows', async () => { await withPlatform('win32', () => { - assert.equal(resolveExecFileCommand('openclaw'), 'openclaw') + assert.equal(resolveExecFileCommand('clawprobe'), 'clawprobe.cmd') + }) +}) + +test('resolveExecFileCommand keeps ollama bare on Windows (native binary)', async () => { + await withPlatform('win32', () => { + assert.equal(resolveExecFileCommand('ollama'), 'ollama') }) }) @@ -50,6 +59,7 @@ test('resolveExecFileCommand returns bare command on non-Windows for all command assert.equal(resolveExecFileCommand('clawhub'), 'clawhub') assert.equal(resolveExecFileCommand('ollama'), 'ollama') assert.equal(resolveExecFileCommand('openclaw'), 'openclaw') + assert.equal(resolveExecFileCommand('clawprobe'), 'clawprobe') }) }) @@ -59,13 +69,14 @@ test('needsShellOnWindows returns true for npm-installed commands on Windows', a await withPlatform('win32', () => { assert.equal(needsShellOnWindows('npm'), true) assert.equal(needsShellOnWindows('clawhub'), true) + assert.equal(needsShellOnWindows('openclaw'), true) + assert.equal(needsShellOnWindows('clawprobe'), true) }) }) test('needsShellOnWindows returns false for native binaries on Windows', async () => { await withPlatform('win32', () => { assert.equal(needsShellOnWindows('ollama'), false) - assert.equal(needsShellOnWindows('openclaw'), false) }) }) @@ -74,6 +85,8 @@ test('needsShellOnWindows returns false for everything on non-Windows', async () assert.equal(needsShellOnWindows('npm'), false) assert.equal(needsShellOnWindows('clawhub'), false) assert.equal(needsShellOnWindows('ollama'), false) + assert.equal(needsShellOnWindows('openclaw'), false) + assert.equal(needsShellOnWindows('clawprobe'), false) }) }) @@ -106,3 +119,20 @@ test('execNpmInstallGlobalFile keeps non-Windows npm resolution path', async () assert.notEqual(result.code, 0) }) }) + +test('Darwin node candidate discovery keeps the real user home when HOME points at an isolated profile', async () => { + await withPlatform('darwin', () => { + const originalHome = process.env.HOME + const actualUserHome = os.userInfo().homedir + process.env.HOME = '/tmp/clawmaster-proof-home' + try { + const homeRoots = getDarwinNodeHomeRootsForTests() + assert.ok(homeRoots.includes('/tmp/clawmaster-proof-home')) + assert.ok(homeRoots.includes(actualUserHome)) + assert.ok(getDarwinNodeCandidatePathsForTests().includes(process.execPath)) + } finally { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + } + }) +}) diff --git a/packages/backend/src/execOpenclaw.ts b/packages/backend/src/execOpenclaw.ts index f9d88aa3..dce40485 100644 --- a/packages/backend/src/execOpenclaw.ts +++ b/packages/backend/src/execOpenclaw.ts @@ -115,6 +115,25 @@ function getNodeVersionForBin(nodeBin: string): { major: number; minor: number; } } +function collectDarwinNodeHomeRoots(): string[] { + const homeRoots = new Set() + const maybeAddHome = (candidate: string | undefined) => { + const value = candidate?.trim() + if (!value) return + homeRoots.add(value) + } + + maybeAddHome(process.env.HOME) + maybeAddHome(os.homedir()) + try { + maybeAddHome(os.userInfo().homedir) + } catch { + /* ignore */ + } + + return Array.from(homeRoots) +} + function collectDarwinNodeCandidates(): string[] { const out = new Set() const maybeAdd = (candidate: string) => { @@ -128,19 +147,29 @@ function collectDarwinNodeCandidates(): string[] { maybeAdd('/usr/local/bin/node') maybeAdd('/usr/bin/node') - const nvmDir = path.join(os.homedir(), '.nvm', 'versions', 'node') - try { - const versions = fs.readdirSync(nvmDir) - for (const version of versions) { - maybeAdd(path.join(nvmDir, version, 'bin', 'node')) + for (const homeRoot of collectDarwinNodeHomeRoots()) { + const nvmDir = path.join(homeRoot, '.nvm', 'versions', 'node') + try { + const versions = fs.readdirSync(nvmDir) + for (const version of versions) { + maybeAdd(path.join(nvmDir, version, 'bin', 'node')) + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } return Array.from(out) } +export function getDarwinNodeCandidatePathsForTests(): string[] { + return collectDarwinNodeCandidates() +} + +export function getDarwinNodeHomeRootsForTests(): string[] { + return collectDarwinNodeHomeRoots() +} + function resolveDarwinCompatibleNodeBin(): string | null { if (cachedDarwinCompatibleNodeBin !== undefined) { return cachedDarwinCompatibleNodeBin @@ -297,52 +326,68 @@ function execOpenclawSpawnStdin( ): Promise<{ code: number; stdout: string; stderr: string }> { const maxBuffer = 20 * 1024 * 1024 return new Promise((resolve, reject) => { + const isWin32 = process.platform === 'win32' const child = spawn(bin, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'], + shell: isWin32, + windowsHide: true, }) let killedByTimeout = false let timer: ReturnType | undefined + /** On Windows, `shell: true` spawns cmd.exe as the direct child; killing the + * child only kills cmd.exe, leaving the real process orphaned. Use + * `taskkill /T /F /PID` to kill the entire process tree instead. + * On non-Windows, send SIGKILL directly (immediate force-kill for + * buffer overflow; graceful SIGTERM is handled in the timeout path). */ + const forceKill = () => { + if (isWin32 && child.pid) { + try { + execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' }) + } catch { + try { child.kill('SIGKILL') } catch { /* ignore */ } + } + } else { + try { child.kill('SIGKILL') } catch { /* ignore */ } + } + } + const accOut = { s: '' } const accErr = { s: '' } child.stdout?.on('data', (b) => { accOut.s += b.toString('utf8') if (accOut.s.length > maxBuffer) { - try { - child.kill('SIGKILL') - } catch { - /* ignore */ - } + forceKill() } }) child.stderr?.on('data', (b) => { accErr.s += b.toString('utf8') if (accErr.s.length > maxBuffer) { - try { - child.kill('SIGKILL') - } catch { - /* ignore */ - } + forceKill() } }) if (opts.timeoutMs != null && opts.timeoutMs > 0) { timer = setTimeout(() => { killedByTimeout = true - try { - child.kill('SIGTERM') - } catch { - /* ignore */ - } - const killHard = setTimeout(() => { + if (isWin32) { + forceKill() + } else { try { - child.kill('SIGKILL') + child.kill('SIGTERM') } catch { /* ignore */ } - }, 5000) - killHard.unref() + const killHard = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + /* ignore */ + } + }, 5000) + killHard.unref() + } }, opts.timeoutMs) } @@ -415,6 +460,8 @@ export function execOpenclaw( const execOpts: ExecOpenclawFileOpts = { maxBuffer: 20 * 1024 * 1024, env: command.env, + shell: process.platform === 'win32', + windowsHide: true, } if (opts?.timeoutMs != null && opts.timeoutMs > 0) { execOpts.timeout = opts.timeoutMs @@ -515,7 +562,7 @@ export function execShellCommand(command: string): Promise<{ }) } -const NPM_INSTALLED_COMMANDS = new Set(['npm', 'clawhub']) +const NPM_INSTALLED_COMMANDS = new Set(['npm', 'clawhub', 'openclaw', 'clawprobe']) export function resolveExecFileCommand(cmd: string): string { if (process.platform === 'win32' && NPM_INSTALLED_COMMANDS.has(cmd)) { @@ -725,10 +772,26 @@ export function spawnOpenclawGatewayStart(): Promise { repairDarwinGatewayLaunchAgentIfNeeded() .then(() => { const command = resolveOpenclawCommand() + const isWin32 = process.platform === 'win32' const child = spawn(command.bin, [...command.argsPrefix, 'gateway', 'start'], { stdio: ['ignore', 'pipe', 'pipe'], env: command.env, + shell: isWin32, + windowsHide: true, }) + /** On Windows, kill the entire process tree (cmd.exe + child) so + * orphaned openclaw processes don't survive a gateway start failure. */ + const killTree = () => { + if (isWin32 && child.pid) { + try { + execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' }) + } catch { + try { child.kill() } catch { /* ignore */ } + } + } else { + try { child.kill() } catch { /* ignore */ } + } + } let out = '' let err = '' child.stdout?.on('data', (c) => { @@ -737,7 +800,10 @@ export function spawnOpenclawGatewayStart(): Promise { child.stderr?.on('data', (c) => { err += String(c) }) - child.once('error', reject) + child.once('error', (e) => { + killTree() + reject(e) + }) child.once('close', (code) => { if (code === 0) resolve() else { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index dd26732e..5189dc77 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -5,6 +5,7 @@ import express from 'express' import { registerDomainRoutes, registerDomainJsonRoutes, attachLogsStreamServer } from './routes/index.js' import { requireServiceAuth } from './serviceAuth.js' import { syncInstalledBundledSkills } from './services/bundledSkills.js' +import { isGatewayWatchdogEnabledByEnv, startGatewayWatchdog, stopGatewayWatchdog } from './services/gatewayService.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const CLAWPROBE_COST_DIGEST_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/clawprobe-cost-digest') @@ -12,6 +13,7 @@ const ERNIE_IMAGE_PLUGIN_ROOT = path.resolve(__dirname, '../../../plugins/opencl const CONTENT_DRAFT_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/content-draft') const ERNIE_IMAGE_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/ernie-image') const MODELS_DEV_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/models-dev') +const PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/package-download-tracker') const PADDLEOCR_SKILL_ROOT = path.resolve(__dirname, '../../../bundled-skills/paddleocr-doc-parsing') if (fs.existsSync(path.join(CLAWPROBE_COST_DIGEST_SKILL_ROOT, 'SKILL.md'))) { @@ -29,6 +31,9 @@ if (fs.existsSync(path.join(ERNIE_IMAGE_SKILL_ROOT, 'SKILL.md'))) { if (fs.existsSync(path.join(MODELS_DEV_SKILL_ROOT, 'SKILL.md'))) { process.env.CLAWMASTER_BUNDLED_MODELS_DEV_SKILL_ROOT = MODELS_DEV_SKILL_ROOT } +if (fs.existsSync(path.join(PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT, 'SKILL.md'))) { + process.env.CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT = PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT +} if (fs.existsSync(path.join(PADDLEOCR_SKILL_ROOT, 'SKILL.md'))) { process.env.CLAWMASTER_BUNDLED_PADDLEOCR_DOC_PARSING_SKILL_ROOT = PADDLEOCR_SKILL_ROOT } @@ -93,6 +98,14 @@ export function startServer() { console.log(`ClawMaster service listening on http://${host}:${port} (${uiStatus})`) }) + if (isGatewayWatchdogEnabledByEnv()) { + const status = startGatewayWatchdog() + console.log(`ClawMaster OpenClaw gateway safeguard enabled (interval ${status.intervalMs}ms)`) + server.once('close', () => { + stopGatewayWatchdog() + }) + } + attachLogsStreamServer(server) return server } diff --git a/packages/backend/src/npmOpenclaw.ts b/packages/backend/src/npmOpenclaw.ts index cd270541..cd7c81d2 100644 --- a/packages/backend/src/npmOpenclaw.ts +++ b/packages/backend/src/npmOpenclaw.ts @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import { execNpmCommand, execNpmInstallGlobalFile, execShellCommand } from './execOpenclaw.js' +import { execNpmCommand, execNpmInstallGlobalFile } from './execOpenclaw.js' import { expandUserPath } from './paths.js' /** Block npm arg injection; allow typical semver / dist-tag characters only */ @@ -68,16 +68,16 @@ function compareVersionDesc(a: string, b: string): number { const MAX_VERSIONS_LIST = 120 -export async function fetchOpenclawNpmMeta(): Promise<{ +export async function fetchNpmPackageMeta(packageName: string): Promise<{ versions: string[] distTags: Record }> { const [vRes, tRes] = await Promise.all([ - execShellCommand('npm view openclaw versions --json'), - execShellCommand('npm view openclaw dist-tags --json'), + execNpmCommand(['view', packageName, 'versions', '--json']), + execNpmCommand(['view', packageName, 'dist-tags', '--json']), ]) if (vRes.code !== 0) { - throw new Error(vRes.stderr || vRes.stdout || 'npm view openclaw versions failed') + throw new Error(vRes.stderr || vRes.stdout || `npm view ${packageName} versions failed`) } let versions = parseVersionsJson(vRes.stdout) versions = [...new Set(versions)].sort(compareVersionDesc) @@ -89,6 +89,20 @@ export async function fetchOpenclawNpmMeta(): Promise<{ return { versions, distTags } } +export async function fetchOpenclawNpmMeta(): Promise<{ + versions: string[] + distTags: Record +}> { + return fetchNpmPackageMeta('openclaw') +} + +export async function fetchClawmasterNpmMeta(): Promise<{ + versions: string[] + distTags: Record +}> { + return fetchNpmPackageMeta('clawmaster') +} + export async function npmInstallOpenclawGlobal(versionSpec: string): Promise<{ ok: boolean code: number diff --git a/packages/backend/src/npmProxy.test.ts b/packages/backend/src/npmProxy.test.ts index b478e288..25e0fc73 100644 --- a/packages/backend/src/npmProxy.test.ts +++ b/packages/backend/src/npmProxy.test.ts @@ -50,3 +50,17 @@ test('applyConfiguredNpmRegistryArgs does not duplicate an explicit --registry f ) }) }) + +test('applyConfiguredNpmRegistryArgs appends the configured registry to npm view commands', () => { + withTempHome((homeDir) => { + writeClawmasterSettings( + { npmProxy: { enabled: true } }, + { homeDir, settingsPath: path.join(homeDir, '.clawmaster', 'settings.json') } + ) + + assert.deepEqual( + applyConfiguredNpmRegistryArgs(['view', 'clawmaster', 'dist-tags', '--json']), + ['view', 'clawmaster', 'dist-tags', '--json', '--registry', 'https://registry.npmmirror.com'] + ) + }) +}) diff --git a/packages/backend/src/npmProxy.ts b/packages/backend/src/npmProxy.ts index 4cab94be..362accba 100644 --- a/packages/backend/src/npmProxy.ts +++ b/packages/backend/src/npmProxy.ts @@ -1,6 +1,6 @@ import { getClawmasterNpmProxyRegistryUrl } from './clawmasterSettings.js' -const NPM_INSTALL_COMMANDS = new Set(['install', 'i']) +const NPM_PROXY_COMMANDS = new Set(['install', 'i', 'view']) function hasRegistryOverride(args: string[]): boolean { return args.some((arg) => arg === '--registry' || arg.startsWith('--registry=')) @@ -8,7 +8,7 @@ function hasRegistryOverride(args: string[]): boolean { function shouldApplyRegistryProxy(args: string[]): boolean { const subcommand = args[0]?.trim().toLowerCase() - return Boolean(subcommand && NPM_INSTALL_COMMANDS.has(subcommand) && !hasRegistryOverride(args)) + return Boolean(subcommand && NPM_PROXY_COMMANDS.has(subcommand) && !hasRegistryOverride(args)) } export function applyConfiguredNpmRegistryArgs(args: string[]): string[] { diff --git a/packages/backend/src/npmUninstallGlobalRobust.test.ts b/packages/backend/src/npmUninstallGlobalRobust.test.ts new file mode 100644 index 00000000..4d0c98cc --- /dev/null +++ b/packages/backend/src/npmUninstallGlobalRobust.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { npmUninstallGlobalRobust } from './npmUninstallGlobalRobust.js' + +function makeTempDir(label: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `clawmaster-${label}-`)) +} + +test('npmUninstallGlobalRobust rejects unsupported package names before invoking npm', async () => { + let called = false + + const result = await npmUninstallGlobalRobust('openclaw; rm -rf /' as 'openclaw' | 'clawhub', { + execNpm: async () => { + called = true + return { code: 0, stdout: '', stderr: '' } + }, + }) + + assert.equal(result.code, 1) + assert.equal(result.stderr, 'unsupported package') + assert.equal(called, false) +}) + +test('npmUninstallGlobalRobust uses argv-based npm execution for uninstall fallback flow', async () => { + const globalRoot = makeTempDir('npm-uninstall-root') + const packageDir = path.join(globalRoot, 'clawhub') + fs.mkdirSync(packageDir, { recursive: true }) + + const calls: string[][] = [] + const result = await npmUninstallGlobalRobust('clawhub', { + execNpm: async (args) => { + calls.push(args) + if (calls.length === 1) { + return { code: 1, stdout: '', stderr: 'ENOTEMPTY rename failed' } + } + if (calls.length === 2) { + return { code: 1, stdout: '', stderr: 'still failing after force' } + } + if (calls.length === 3) { + return { code: 0, stdout: globalRoot, stderr: '' } + } + throw new Error(`Unexpected npm call: ${args.join(' ')}`) + }, + }) + + assert.deepEqual(calls, [ + ['uninstall', '-g', 'clawhub'], + ['uninstall', '-g', 'clawhub', '--force'], + ['root', '-g'], + ]) + assert.equal(result.code, 0) + assert.match(result.stdout, /Removed global dir:/) + assert.equal(fs.existsSync(packageDir), false) +}) diff --git a/packages/backend/src/npmUninstallGlobalRobust.ts b/packages/backend/src/npmUninstallGlobalRobust.ts index ec81ab30..6d87e9ad 100644 --- a/packages/backend/src/npmUninstallGlobalRobust.ts +++ b/packages/backend/src/npmUninstallGlobalRobust.ts @@ -1,9 +1,15 @@ import fs from 'fs' import path from 'path' -import { execShellCommand } from './execOpenclaw.js' +import { execNpmCommand } from './execOpenclaw.js' const ALLOWED = new Set(['openclaw', 'clawhub']) +type NpmCommandRunner = (args: string[]) => Promise<{ + code: number + stdout: string + stderr: string +}> + function looksLikeNpmRenameIssue(combined: string): boolean { return /ENOTEMPTY|rename|EPERM|EACCES|EEXIST/i.test(combined) } @@ -15,18 +21,20 @@ function looksLikeNpmRenameIssue(combined: string): boolean { * 3) else remove $(npm root -g)/ (only openclaw / clawhub allowed) */ export async function npmUninstallGlobalRobust( - packageName: 'openclaw' | 'clawhub' + packageName: 'openclaw' | 'clawhub', + deps: { execNpm?: NpmCommandRunner } = {} ): Promise<{ code: number; stdout: string; stderr: string }> { if (!ALLOWED.has(packageName)) { return { code: 1, stdout: '', stderr: 'unsupported package' } } + const runNpm = deps.execNpm ?? execNpmCommand - let r = await execShellCommand(`npm uninstall -g ${packageName}`) + let r = await runNpm(['uninstall', '-g', packageName]) if (r.code === 0) return r const combined = `${r.stderr}\n${r.stdout}` if (looksLikeNpmRenameIssue(combined)) { - const f = await execShellCommand(`npm uninstall -g ${packageName} --force`) + const f = await runNpm(['uninstall', '-g', packageName, '--force']) r = { code: f.code, stdout: [r.stdout, f.stdout].filter(Boolean).join('\n'), @@ -35,7 +43,7 @@ export async function npmUninstallGlobalRobust( if (r.code === 0) return r } - const rootRes = await execShellCommand('npm root -g') + const rootRes = await runNpm(['root', '-g']) if (rootRes.code !== 0) { return { code: r.code, diff --git a/packages/backend/src/openclawVaultPaths.test.ts b/packages/backend/src/openclawVaultPaths.test.ts new file mode 100644 index 00000000..16c75954 --- /dev/null +++ b/packages/backend/src/openclawVaultPaths.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + ensureWikiVaultStructure, + resolveOpenclawStateDirForVault, + resolveOpenclawStateDirFromManagedDataRoot, + resolveWikiVaultLayout, + resolveWikiVaultRoot, + WIKI_SCHEMA_MARKDOWN, +} from './openclawVaultPaths.js' + +function emptyEnv(): Record { + return {} +} + +test('resolveWikiVaultRoot honors explicit vault root override before all other inputs', () => { + const vaultRoot = resolveWikiVaultRoot({ + vaultRootOverride: '/tmp/explicit-vault', + env: { OPENCLAW_STATE_DIR: '/tmp/other', CLAWMASTER_WIKI_ROOT: '/tmp/env-override' }, + homeDir: '/home/testuser', + profileSelection: { kind: 'named', name: 'work' }, + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(vaultRoot, '/tmp/explicit-vault') +}) + +test('resolveWikiVaultRoot honors CLAWMASTER_WIKI_ROOT env when no explicit override is supplied', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: { CLAWMASTER_WIKI_ROOT: '/tmp/env-vault' }, + homeDir: '/home/testuser', + }) + assert.equal(vaultRoot, '/tmp/env-vault') +}) + +test('resolveWikiVaultRoot prefers OPENCLAW_STATE_DIR over profile selection', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: { OPENCLAW_STATE_DIR: '/opt/openclaw-state' }, + homeDir: '/home/testuser', + profileSelection: { kind: 'named', name: 'work' }, + platform: 'linux', + }) + assert.equal(vaultRoot, path.posix.join('/opt/openclaw-state', 'wiki')) +}) + +test('resolveWikiVaultRoot derives a named-profile state dir from the managed data root', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(vaultRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki')) +}) + +test('resolveWikiVaultRoot derives a dev state dir from the managed data root', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/dev', + }) + assert.equal(vaultRoot, path.posix.join('/home/testuser/.openclaw-dev', 'wiki')) +}) + +test('resolveWikiVaultRoot derives a default state dir from the managed data root', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/default', + }) + assert.equal(vaultRoot, path.posix.join('/home/testuser/.openclaw', 'wiki')) +}) + +test('resolveWikiVaultRoot falls back to profile selection + home when no overrides are supplied', () => { + const defaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + profileSelection: { kind: 'default' }, + }) + assert.equal(defaultRoot, path.posix.join('/home/testuser/.openclaw', 'wiki')) + + const devRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + profileSelection: { kind: 'dev' }, + }) + assert.equal(devRoot, path.posix.join('/home/testuser/.openclaw-dev', 'wiki')) + + const namedRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + homeDir: '/home/testuser', + platform: 'linux', + profileSelection: { kind: 'named', name: 'work' }, + }) + assert.equal(namedRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki')) +}) + +test('resolveWikiVaultRoot uses windows path semantics when a windows data root is supplied', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: emptyEnv(), + platform: 'win32', + dataRootOverride: 'C:\\Users\\dev\\.clawmaster\\data\\default', + }) + assert.equal(vaultRoot, path.win32.join('C:\\Users\\dev\\.openclaw', 'wiki')) +}) + +test('resolveWikiVaultRoot prefers /home/ over /mnt/c/users/ when WSL data root is supplied', () => { + const vaultRoot = resolveWikiVaultRoot({ + env: { HOME: '/home/wsluser' }, + platform: 'linux', + dataRootOverride: '/mnt/c/users/dev/.clawmaster/data/default', + }) + assert.equal(vaultRoot, path.posix.join('/home/wsluser/.openclaw', 'wiki')) +}) + +test('resolveOpenclawStateDirFromManagedDataRoot returns null for unrecognised managed data roots', () => { + assert.equal( + resolveOpenclawStateDirFromManagedDataRoot('/tmp/something/else', {}), + null, + ) + assert.equal( + resolveOpenclawStateDirFromManagedDataRoot('', {}), + null, + ) +}) + +test('resolveOpenclawStateDirForVault keeps OPENCLAW_STATE_DIR precedence over dataRootOverride', () => { + const stateDir = resolveOpenclawStateDirForVault({ + env: { OPENCLAW_STATE_DIR: '/opt/override' }, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(stateDir, '/opt/override') +}) + +test('resolveWikiVaultLayout returns all derived paths below the resolved vault root', () => { + const layout = resolveWikiVaultLayout({ + vaultRootOverride: '/tmp/vault', + platform: 'linux', + }) + assert.equal(layout.vaultRoot, '/tmp/vault') + assert.equal(layout.rawRoot, path.posix.join('/tmp/vault', 'raw')) + assert.equal(layout.pagesRoot, path.posix.join('/tmp/vault', 'pages')) + assert.equal(layout.metaRoot, path.posix.join('/tmp/vault', '.meta')) + assert.equal(layout.indexPath, path.posix.join('/tmp/vault', 'index.md')) + assert.equal(layout.logPath, path.posix.join('/tmp/vault', 'log.md')) + assert.equal(layout.schemaPath, path.posix.join('/tmp/vault', 'SCHEMA.md')) + assert.equal(layout.freshnessPath, path.posix.join('/tmp/vault/.meta', 'freshness.json')) + assert.equal(layout.conflictsPath, path.posix.join('/tmp/vault/.meta', 'conflicts.json')) + assert.equal(layout.ingestStatePath, path.posix.join('/tmp/vault/.meta', 'ingest-state.json')) +}) + +test('ensureWikiVaultStructure creates directories, meta files, and the shared schema markdown', async () => { + const vaultRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'clawmaster-vault-')) + try { + const layout = resolveWikiVaultLayout({ vaultRootOverride: vaultRoot }) + await ensureWikiVaultStructure(layout) + + for (const subdir of ['raw', '.meta', 'pages/sources', 'pages/entities', 'pages/concepts', 'pages/synthesis', 'pages/processes']) { + const stat = await fs.stat(path.join(vaultRoot, subdir)) + assert.ok(stat.isDirectory(), `${subdir} should be a directory`) + } + + const schema = await fs.readFile(layout.schemaPath, 'utf8') + assert.equal(schema, `${WIKI_SCHEMA_MARKDOWN}\n`) + + const freshness = await fs.readFile(layout.freshnessPath, 'utf8') + assert.equal(freshness.trim(), '{}') + + const conflicts = await fs.readFile(layout.conflictsPath, 'utf8') + assert.equal(conflicts.trim(), '[]') + + const state = JSON.parse(await fs.readFile(layout.ingestStatePath, 'utf8')) as { + version: number + sources: Record + } + assert.equal(state.version, 1) + assert.deepEqual(state.sources, {}) + } finally { + await fs.rm(vaultRoot, { recursive: true, force: true }) + } +}) + +test('ensureWikiVaultStructure is idempotent and does not overwrite existing content', async () => { + const vaultRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'clawmaster-vault-idempotent-')) + try { + const layout = resolveWikiVaultLayout({ vaultRootOverride: vaultRoot }) + await ensureWikiVaultStructure(layout) + + const customSchema = '# Custom schema (user edited)\n' + await fs.writeFile(layout.schemaPath, customSchema, 'utf8') + await fs.writeFile(layout.logPath, '# User custom log\n\n- a personal log entry\n', 'utf8') + await fs.writeFile(layout.freshnessPath, '{"foo":1}\n', 'utf8') + + await ensureWikiVaultStructure(layout) + + assert.equal(await fs.readFile(layout.schemaPath, 'utf8'), customSchema) + assert.match(await fs.readFile(layout.logPath, 'utf8'), /personal log entry/) + assert.equal(JSON.parse(await fs.readFile(layout.freshnessPath, 'utf8')).foo, 1) + } finally { + await fs.rm(vaultRoot, { recursive: true, force: true }) + } +}) + +test('resolveWikiVaultLayout uses dataRootOverride when vaultRootOverride is absent', () => { + const layout = resolveWikiVaultLayout({ + env: emptyEnv(), + platform: 'linux', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + }) + assert.equal(layout.vaultRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki')) + assert.equal(layout.pagesRoot, path.posix.join('/home/testuser/.openclaw-work', 'wiki', 'pages')) + assert.equal(layout.ingestStatePath, path.posix.join('/home/testuser/.openclaw-work', 'wiki', '.meta', 'ingest-state.json')) +}) + +test('resolveWikiVaultRoot matches the plugin-side resolution for representative managed data roots (parity)', () => { + const cases = [ + { + platform: 'linux' as const, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/default', + expected: '/home/testuser/.openclaw/wiki', + }, + { + platform: 'linux' as const, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/named/work', + expected: '/home/testuser/.openclaw-work/wiki', + }, + { + platform: 'linux' as const, + homeDir: '/home/testuser', + dataRootOverride: '/home/testuser/.clawmaster/data/dev', + expected: '/home/testuser/.openclaw-dev/wiki', + }, + ] + + for (const { platform, homeDir, dataRootOverride, expected } of cases) { + const backendResolution = resolveWikiVaultRoot({ + env: emptyEnv(), + platform, + homeDir, + dataRootOverride, + }) + assert.equal(backendResolution, expected) + + // Plugin resolution today is `resolveOpenclawWorkspaceDir(ctx)` joined with '../wiki'. + // Proving the derived-state-dir matches our shared helper is enough to guarantee + // the plugin's join(..., '..', 'wiki') lands on the same vault root without + // creating a circular plugin→backend import in production code. + const stateDir = resolveOpenclawStateDirFromManagedDataRoot(dataRootOverride, {}) + assert.equal(stateDir, expected.replace(/\/wiki$/, '')) + } +}) diff --git a/packages/backend/src/openclawVaultPaths.ts b/packages/backend/src/openclawVaultPaths.ts new file mode 100644 index 00000000..fde0492b --- /dev/null +++ b/packages/backend/src/openclawVaultPaths.ts @@ -0,0 +1,224 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + getOpenclawPathModule, + getOpenclawProfileSelection, + type OpenclawProfileContext, + type OpenclawProfileSelection, +} from './openclawProfile.js' + +export interface VaultPathContext extends OpenclawProfileContext { + profileSelection?: OpenclawProfileSelection + vaultRootOverride?: string + dataRootOverride?: string + env?: Record +} + +export const WIKI_SCHEMA_MARKDOWN = `# Wiki Schema + +This vault stores durable, citation-aware knowledge for OpenClaw and ClawMaster workflows. + +## Layout + +- \`raw/\`: original imported artifacts or fetched source payloads when stored later +- \`pages/sources/\`: imported source notes and source-linked summaries +- \`pages/entities/\`: people, orgs, tools, and products enriched from sources +- \`pages/concepts/\`: patterns, ideas, and reusable techniques enriched from sources +- \`pages/synthesis/\`: durable synthesized answers created from source pages +- \`pages/processes/\`: process docs and operating procedures +- \`.meta/freshness.json\`: computed freshness state +- \`.meta/conflicts.json\`: lint issues considered knowledge conflicts +- \`.meta/ingest-state.json\`: source-to-page provenance for incremental re-ingest + +## Required frontmatter + +- \`id\`: stable page id +- \`title\`: human-readable page title +- \`type\`: one of entity, concept, source, synthesis, process +- \`createdAt\`, \`updatedAt\` +- \`freshnessScore\`, \`freshnessStatus\` +- \`memoryId\`: managed memory backing record id when present + +## Generated provenance + +- \`generatedFromSourceIds\`: YAML array of source page ids whose generated blocks contribute to this page (e.g. \`["sources-foo", "sources-bar"]\`) +- \`relatedPages\`: YAML array of related page titles derived from this source (replaces the legacy body-level "Extracted Wiki Links" section) +- Generated blocks use HTML comments of the form \`\` +- Re-ingest replaces or removes only the generated block for the matching source page id + +## Linking and citations + +- Use \`[[Page Title]]\` wiki-style links for cross-references in body text +- Frontmatter \`relatedPages\` arrays are unioned with body \`[[links]]\` for backlink calculations and are visible to dataview-style queries in external editors +- Source pages should preserve provenance via \`sourceUrl\` and \`sourcePath\` +- Synthesis pages should cite source pages with \`[[Page Title]]\` links + +## Maintenance + +- Mechanical evolve recalculates freshness, related pages, and structural health +- Deep evolve is opt-in and may revise stale pages with LLM review +- Lint checks structure first, then optional contradiction checks across related pages +` + +const PAGE_SUBDIRS = ['sources', 'entities', 'concepts', 'synthesis', 'processes'] as const + +function isWindowsDrivePath(value: string): boolean { + return /^[A-Za-z]:\//.test(value) +} + +function toForwardSlashes(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function fromForwardSlashes(value: string, windowsStyle: boolean): string { + return windowsStyle ? value.replace(/\//g, '\\') : value +} + +function resolvePreferredPosixHomeForMountedManagedDataRoot( + normalizedDataRoot: string, + env: Record, +): string | null { + const homeDir = env['HOME']?.trim() + if (!homeDir || !homeDir.startsWith('/home/')) return null + if (!/^\/mnt\/[a-z]\/users\/[^/]+\/\.clawmaster\/data\//i.test(normalizedDataRoot)) return null + return homeDir +} + +export function resolveOpenclawStateDirFromManagedDataRoot( + dataRoot: string, + env: Record = process.env, +): string | null { + const normalized = toForwardSlashes(dataRoot.trim()) + if (!normalized) return null + + const windowsStyle = isWindowsDrivePath(normalized) + const preferredPosixHome = !windowsStyle + ? resolvePreferredPosixHomeForMountedManagedDataRoot(normalized, env) + : null + + const namedMatch = /^(.*)\/\.clawmaster\/data\/named\/([^/]+)$/.exec(normalized) + if (namedMatch) { + const homeDir = preferredPosixHome ?? fromForwardSlashes(namedMatch[1]!, windowsStyle) + const profileName = namedMatch[2]! + return windowsStyle + ? path.win32.join(homeDir, `.openclaw-${profileName}`) + : path.posix.join(homeDir, `.openclaw-${profileName}`) + } + + const devMatch = /^(.*)\/\.clawmaster\/data\/dev$/.exec(normalized) + if (devMatch) { + const homeDir = preferredPosixHome ?? fromForwardSlashes(devMatch[1]!, windowsStyle) + return windowsStyle + ? path.win32.join(homeDir, '.openclaw-dev') + : path.posix.join(homeDir, '.openclaw-dev') + } + + const defaultMatch = /^(.*)\/\.clawmaster\/data\/default$/.exec(normalized) + if (defaultMatch) { + const homeDir = preferredPosixHome ?? fromForwardSlashes(defaultMatch[1]!, windowsStyle) + return windowsStyle + ? path.win32.join(homeDir, '.openclaw') + : path.posix.join(homeDir, '.openclaw') + } + + return null +} + +function resolveStateDirFromProfile( + profileSelection: OpenclawProfileSelection, + context: VaultPathContext, +): string { + const pathModule = getOpenclawPathModule(context.platform) + const homeDir = context.homeDir ?? os.homedir() + if (profileSelection.kind === 'named' && profileSelection.name) { + return pathModule.join(homeDir, `.openclaw-${profileSelection.name}`) + } + if (profileSelection.kind === 'dev') { + return pathModule.join(homeDir, '.openclaw-dev') + } + return pathModule.join(homeDir, '.openclaw') +} + +export function resolveOpenclawStateDirForVault(context: VaultPathContext = {}): string { + const env = context.env ?? process.env + const stateDirEnv = env['OPENCLAW_STATE_DIR']?.trim() + if (stateDirEnv) return stateDirEnv + + if (context.dataRootOverride) { + const derived = resolveOpenclawStateDirFromManagedDataRoot(context.dataRootOverride, env) + if (derived) return derived + } + + const profileSelection = context.profileSelection ?? getOpenclawProfileSelection(context) + return resolveStateDirFromProfile(profileSelection, context) +} + +export function resolveWikiVaultRoot(context: VaultPathContext = {}): string { + const env = context.env ?? process.env + if (context.vaultRootOverride?.trim()) return context.vaultRootOverride.trim() + + const envOverride = env['CLAWMASTER_WIKI_ROOT']?.trim() + if (envOverride) return envOverride + + const stateDir = resolveOpenclawStateDirForVault(context) + const pathModule = getOpenclawPathModule(context.platform) + return pathModule.join(stateDir, 'wiki') +} + +export interface WikiVaultLayout { + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string + ingestStatePath: string +} + +export function resolveWikiVaultLayout(context: VaultPathContext = {}): WikiVaultLayout { + const vaultRoot = resolveWikiVaultRoot(context) + const pathModule = getOpenclawPathModule(context.platform) + const pagesRoot = pathModule.join(vaultRoot, 'pages') + const metaRoot = pathModule.join(vaultRoot, '.meta') + return { + vaultRoot, + rawRoot: pathModule.join(vaultRoot, 'raw'), + pagesRoot, + metaRoot, + indexPath: pathModule.join(vaultRoot, 'index.md'), + logPath: pathModule.join(vaultRoot, 'log.md'), + schemaPath: pathModule.join(vaultRoot, 'SCHEMA.md'), + freshnessPath: pathModule.join(metaRoot, 'freshness.json'), + conflictsPath: pathModule.join(metaRoot, 'conflicts.json'), + ingestStatePath: pathModule.join(metaRoot, 'ingest-state.json'), + } +} + +async function writeIfMissing(filePath: string, content: string): Promise { + try { + await fs.access(filePath) + } catch { + await fs.writeFile(filePath, content, 'utf8') + } +} + +export async function ensureWikiVaultStructure(layout: WikiVaultLayout): Promise { + await fs.mkdir(layout.rawRoot, { recursive: true }) + await fs.mkdir(layout.metaRoot, { recursive: true }) + await Promise.all( + PAGE_SUBDIRS.map((dir) => fs.mkdir(path.join(layout.pagesRoot, dir), { recursive: true })), + ) + await writeIfMissing(layout.indexPath, '# Knowledge Index\n\nPages will appear here after ingest.\n') + await writeIfMissing(layout.logPath, '# Knowledge Log\n\n') + await writeIfMissing(layout.schemaPath, `${WIKI_SCHEMA_MARKDOWN}\n`) + await writeIfMissing(layout.freshnessPath, '{}\n') + await writeIfMissing(layout.conflictsPath, '[]\n') + await writeIfMissing( + layout.ingestStatePath, + `${JSON.stringify({ version: 1, sources: {} }, null, 2)}\n`, + ) +} diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 15f03273..2fa33700 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -17,6 +17,7 @@ import { registerStorageRoutes } from './storageRoutes.js' import { registerMcpRoutes } from './mcpRoutes.js' import { registerOllamaRoutes } from './ollamaRoutes.js' import { registerContentDraftRoutes } from './contentDraftRoutes.js' +import { registerWikiRoutes } from './wikiRoutes.js' export function registerDomainRoutes(app: express.Express): void { registerSystemRoutes(app) @@ -34,6 +35,7 @@ export function registerDomainRoutes(app: express.Express): void { registerMcpRoutes(app) registerOllamaRoutes(app) registerContentDraftRoutes(app) + registerWikiRoutes(app) registerExecRoutes(app) registerStorageRoutes(app) } diff --git a/packages/backend/src/routes/npmRoutes.ts b/packages/backend/src/routes/npmRoutes.ts index 02d97bfc..72f4a3d6 100644 --- a/packages/backend/src/routes/npmRoutes.ts +++ b/packages/backend/src/routes/npmRoutes.ts @@ -1,5 +1,5 @@ import type express from 'express' -import { bootstrapAfterInstall, getNpmOpenclawVersions, installOpenclaw, reinstallBackupStep, reinstallOpenclaw, reinstallUninstallStep } from '../services/npmService.js' +import { bootstrapAfterInstall, getNpmClawmasterVersions, getNpmOpenclawVersions, installOpenclaw, reinstallBackupStep, reinstallOpenclaw, reinstallUninstallStep } from '../services/npmService.js' export function registerNpmRoutes(app: express.Express): void { app.get('/api/npm/openclaw-versions', async (_req, res) => { @@ -11,6 +11,15 @@ export function registerNpmRoutes(app: express.Express): void { } }) + app.get('/api/npm/clawmaster-versions', async (_req, res) => { + try { + res.json(await getNpmClawmasterVersions()) + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error) + res.status(502).type('text').send(msg) + } + }) + app.post('/api/npm/install-openclaw', async (req, res) => { try { res.json(await installOpenclaw(req.body as { version?: unknown; localPath?: unknown })) diff --git a/packages/backend/src/routes/wikiRoutes.ts b/packages/backend/src/routes/wikiRoutes.ts new file mode 100644 index 00000000..fa058ac2 --- /dev/null +++ b/packages/backend/src/routes/wikiRoutes.ts @@ -0,0 +1,168 @@ +import express from 'express' +import { + assistWithWiki, + evolveWiki, + evolveWikiDeep, + getWikiPage, + getWikiStatus, + ingestWikiSource, + lintWiki, + listWikiPages, + planWikiLinkChoice, + queryWiki, + searchWiki, + synthesizeWiki, + type WikiIngestInput, + type WikiSynthesizeInput, +} from '../services/wikiService.js' + +const WRITE_ROUTE_CONTEXT = { autoEvolveOnWrite: true } as const + +function parseLimit(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value, 10) : NaN + if (!Number.isFinite(parsed) || parsed < 1) return fallback + return Math.min(50, Math.floor(parsed)) +} + +function sendWikiError(res: express.Response, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + const status = /not found/i.test(message) ? 404 : /required|invalid/i.test(message) ? 400 : 500 + res.status(status).type('text').send(message) +} + +export function registerWikiRoutes(app: express.Express): void { + app.get('/api/wiki/status', async (_req, res) => { + try { + res.json(await getWikiStatus()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.get('/api/wiki/pages', async (_req, res) => { + try { + res.json(await listWikiPages()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.get('/api/wiki/pages/:id', async (req, res) => { + try { + res.json(await getWikiPage(req.params.id)) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/search', express.json(), async (req, res) => { + const body = req.body as { query?: string; limit?: number } + const query = typeof body?.query === 'string' ? body.query.trim() : '' + if (!query) { + return res.status(400).type('text').send('Body must be JSON: { "query": string }') + } + try { + res.json(await searchWiki(query, { limit: parseLimit(body.limit, 12) })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/ingest', express.json({ limit: '2mb' }), async (req, res) => { + const body = req.body as WikiIngestInput + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).type('text').send('Body must be JSON') + } + try { + res.json(await ingestWikiSource(body, WRITE_ROUTE_CONTEXT)) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/query', express.json(), async (req, res) => { + const body = req.body as { query?: string; limit?: number } + const query = typeof body?.query === 'string' ? body.query.trim() : '' + if (!query) { + return res.status(400).type('text').send('Body must be JSON: { "query": string }') + } + try { + res.json(await queryWiki(query, { limit: parseLimit(body.limit, 6) })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/assist', express.json(), async (req, res) => { + const body = req.body as { question?: string; query?: string; limit?: number } + const question = + typeof body?.question === 'string' + ? body.question.trim() + : typeof body?.query === 'string' + ? body.query.trim() + : '' + if (!question) { + return res.status(400).type('text').send('Body must be JSON: { "question": string }') + } + try { + res.json(await assistWithWiki(question, { limit: parseLimit(body.limit, 6) })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/link-choice', express.json(), async (req, res) => { + const body = req.body as { input?: string; text?: string } + const input = + typeof body?.input === 'string' + ? body.input + : typeof body?.text === 'string' + ? body.text + : '' + if (!input.trim()) { + return res.status(400).type('text').send('Body must be JSON: { "input": string }') + } + res.json(planWikiLinkChoice(input)) + }) + + app.post('/api/wiki/synthesize', express.json(), async (req, res) => { + const body = req.body as WikiSynthesizeInput + const query = typeof body?.query === 'string' ? body.query.trim() : '' + if (!query) { + return res.status(400).type('text').send('Body must be JSON: { "query": string }') + } + try { + res.json(await synthesizeWiki({ + query, + title: typeof body.title === 'string' ? body.title : undefined, + limit: parseLimit(body.limit, 5), + }, WRITE_ROUTE_CONTEXT)) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/lint', async (_req, res) => { + try { + res.json(await lintWiki({}, { detectContradictions: false })) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/evolve', async (_req, res) => { + try { + res.json(await evolveWiki()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) + + app.post('/api/wiki/evolve/deep', async (_req, res) => { + try { + res.json(await evolveWikiDeep()) + } catch (error: unknown) { + sendWikiError(res, error) + } + }) +} diff --git a/packages/backend/src/services/bundledSkills.test.ts b/packages/backend/src/services/bundledSkills.test.ts index afae57a3..2d3c57c9 100644 --- a/packages/backend/src/services/bundledSkills.test.ts +++ b/packages/backend/src/services/bundledSkills.test.ts @@ -16,6 +16,8 @@ test('isBundledSkillSlug recognizes bundled skill ids case-insensitively', () => assert.equal(isBundledSkillSlug('ERNIE-IMAGE'), true) assert.equal(isBundledSkillSlug('models-dev'), true) assert.equal(isBundledSkillSlug('MODELS-DEV'), true) + assert.equal(isBundledSkillSlug('package-download-tracker'), true) + assert.equal(isBundledSkillSlug('PACKAGE-DOWNLOAD-TRACKER'), true) assert.equal(isBundledSkillSlug('paddleocr-doc-parsing'), true) assert.equal(isBundledSkillSlug('PADDLEOCR-DOC-PARSING'), true) assert.equal(isBundledSkillSlug('image-generate'), false) @@ -188,6 +190,35 @@ test('installBundledSkill copies the bundled models.dev skill into the active wo ) }) +test('installBundledSkill copies the bundled package download tracker skill into the active workspace', () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-bundled-skill-src-')) + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-bundled-skill-data-')) + fs.mkdirSync(path.join(sourceRoot, 'scripts'), { recursive: true }) + fs.writeFileSync(path.join(sourceRoot, 'SKILL.md'), '# Package Download Tracker\n', 'utf8') + fs.writeFileSync(path.join(sourceRoot, '_meta.json'), '{"slug":"package-download-tracker","version":"1.0.0"}\n', 'utf8') + fs.writeFileSync(path.join(sourceRoot, 'scripts', 'track-downloads.mjs'), 'console.log("track")\n', 'utf8') + + const result = installBundledSkill('package-download-tracker', { + dataDir, + env: { + ...process.env, + CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT: sourceRoot, + }, + }) + + const installDir = path.join(dataDir, 'workspace', 'skills', 'package-download-tracker') + assert.equal(result.installDir, installDir) + assert.equal(fs.readFileSync(path.join(installDir, 'SKILL.md'), 'utf8'), '# Package Download Tracker\n') + assert.equal( + fs.readFileSync(path.join(installDir, '_meta.json'), 'utf8'), + '{"slug":"package-download-tracker","version":"1.0.0"}\n', + ) + assert.equal( + fs.readFileSync(path.join(installDir, 'scripts', 'track-downloads.mjs'), 'utf8'), + 'console.log("track")\n', + ) +}) + test('installBundledSkill uses WSL copy commands for Linux runtime data dirs on Windows', () => { const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-bundled-skill-src-')) fs.mkdirSync(path.join(sourceRoot, 'scripts'), { recursive: true }) @@ -325,7 +356,7 @@ test('syncInstalledBundledSkills refreshes WSL-installed bundled skills with a W }) assert.deepEqual(synced, ['content-draft']) - assert.equal(wslScripts.filter(({ script }) => script.startsWith('test -e ')).length, 5) + assert.equal(wslScripts.filter(({ script }) => script.startsWith('test -e ')).length, 6) assert.equal(wslScripts.filter(({ script }) => script.startsWith('cat ')).length, 1) const copyScript = wslScripts.find(({ script }) => /cp -a/.test(script)) assert.ok(copyScript) @@ -357,6 +388,19 @@ test('bundled models.dev skill explicitly instructs agents to read the skill and assert.match(skillBody, /Then use `exec` to run the bundled Node scripts with `node`\./) }) +test('bundled package download tracker skill explicitly instructs agents to read the skill and exec the script', () => { + const skillPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../bundled-skills/package-download-tracker/SKILL.md', + ) + const skillBody = fs.readFileSync(skillPath, 'utf8') + + assert.match(skillBody, /Do not call `package-download-tracker` as if it were a built-in tool\./) + assert.match(skillBody, /First use `read` to load this `SKILL\.md`\./) + assert.match(skillBody, /Then use `exec` to run the bundled Node script with `node`\./) + assert.match(skillBody, /--load-memory --save-memory/) +}) + test('bundled PaddleOCR skill explicitly instructs agents to read the skill and exec the script', () => { const skillPath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), diff --git a/packages/backend/src/services/bundledSkills.ts b/packages/backend/src/services/bundledSkills.ts index bc9fe608..f3881fd5 100644 --- a/packages/backend/src/services/bundledSkills.ts +++ b/packages/backend/src/services/bundledSkills.ts @@ -25,6 +25,10 @@ const BUNDLED_SKILLS = { dirName: 'models-dev', envKey: 'CLAWMASTER_BUNDLED_MODELS_DEV_SKILL_ROOT', }, + 'package-download-tracker': { + dirName: 'package-download-tracker', + envKey: 'CLAWMASTER_BUNDLED_PACKAGE_DOWNLOAD_TRACKER_SKILL_ROOT', + }, 'paddleocr-doc-parsing': { dirName: 'paddleocr-doc-parsing', envKey: 'CLAWMASTER_BUNDLED_PADDLEOCR_DOC_PARSING_SKILL_ROOT', diff --git a/packages/backend/src/services/gatewayService.test.ts b/packages/backend/src/services/gatewayService.test.ts new file mode 100644 index 00000000..43e423f1 --- /dev/null +++ b/packages/backend/src/services/gatewayService.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + getGatewayWatchdogStatus, + isGatewayWatchdogEnabledByEnv, + startGatewayWatchdog, + stopGatewayWatchdog, +} from './gatewayService.js' + +test.afterEach(() => { + stopGatewayWatchdog() +}) + +test('gateway watchdog is opt-in for backend processes and enabled by CLI env', () => { + assert.equal(isGatewayWatchdogEnabledByEnv({}), false) + assert.equal(isGatewayWatchdogEnabledByEnv({ CLAWMASTER_GATEWAY_WATCHDOG: '1' }), true) + assert.equal(isGatewayWatchdogEnabledByEnv({ CLAWMASTER_GATEWAY_WATCHDOG: 'true' }), true) + assert.equal(isGatewayWatchdogEnabledByEnv({ CLAWMASTER_GATEWAY_WATCHDOG: '0' }), false) + assert.equal(isGatewayWatchdogEnabledByEnv({ CLAWMASTER_GATEWAY_WATCHDOG: 'off' }), false) +}) + +test('gateway watchdog publishes lifecycle status without running an immediate probe', () => { + const started = startGatewayWatchdog({ + intervalMs: 60 * 60 * 1000, + runImmediately: false, + }) + + assert.equal(started.enabled, true) + assert.equal(started.state, 'idle') + assert.equal(started.intervalMs, 60 * 60 * 1000) + assert.equal(started.restartCount, 0) + assert.deepEqual(getGatewayWatchdogStatus(), started) + + const stopped = stopGatewayWatchdog() + assert.equal(stopped.enabled, false) + assert.equal(stopped.state, 'disabled') +}) diff --git a/packages/backend/src/services/gatewayService.ts b/packages/backend/src/services/gatewayService.ts index 00315971..63e138f2 100644 --- a/packages/backend/src/services/gatewayService.ts +++ b/packages/backend/src/services/gatewayService.ts @@ -11,7 +11,67 @@ import { } from '../execOpenclaw.js' import { isRecord } from '../serverUtils.js' -export async function getGatewayStatus() { +const DEFAULT_GATEWAY_WATCHDOG_INTERVAL_MS = 30_000 + +export type GatewayWatchdogState = + | 'disabled' + | 'idle' + | 'healthy' + | 'checking' + | 'restarting' + | 'paused' + | 'error' + +export type GatewayWatchdogStatus = { + enabled: boolean + state: GatewayWatchdogState + intervalMs: number + restartCount: number + lastCheckAt?: string + lastRestartAt?: string + lastError?: string +} + +type GatewayStatusBase = { + running: boolean + port: number +} + +let watchdogTimer: ReturnType | null = null +let watchdogCheckInFlight = false +let watchdogPaused = false +let watchdogStatus: GatewayWatchdogStatus = { + enabled: false, + state: 'disabled', + intervalMs: DEFAULT_GATEWAY_WATCHDOG_INTERVAL_MS, + restartCount: 0, +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function normalizeEnvFlag(value: string | undefined): boolean { + const normalized = String(value ?? '').trim().toLowerCase() + if (!normalized) return false + return !['0', 'false', 'off', 'no'].includes(normalized) +} + +export function isGatewayWatchdogEnabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { + return normalizeEnvFlag(env.CLAWMASTER_GATEWAY_WATCHDOG) +} + +function resolveGatewayWatchdogIntervalMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = Number.parseInt(String(env.CLAWMASTER_GATEWAY_WATCHDOG_INTERVAL_MS ?? ''), 10) + if (Number.isFinite(raw) && raw >= 5_000) return raw + return DEFAULT_GATEWAY_WATCHDOG_INTERVAL_MS +} + +function snapshotWatchdogStatus(): GatewayWatchdogStatus { + return { ...watchdogStatus } +} + +async function readGatewayStatus(): Promise { const cfg = readConfigJson() let port = 18789 const gwc = cfg?.gateway @@ -50,14 +110,175 @@ export async function getGatewayStatus() { return { running: false, port } } +async function waitForGatewayRunning(maxRetries = 5): Promise { + let latest = await readGatewayStatus() + if (latest.running) return latest + for (let attempt = 0; attempt < maxRetries; attempt += 1) { + await sleep(1_000) + latest = await readGatewayStatus() + if (latest.running) return latest + } + return latest +} + +async function runGatewayWatchdogCheck(): Promise { + if (!watchdogStatus.enabled || watchdogCheckInFlight) return + if (watchdogPaused) { + watchdogStatus = { + ...watchdogStatus, + state: 'paused', + lastCheckAt: new Date().toISOString(), + } + return + } + + watchdogCheckInFlight = true + watchdogStatus = { + ...watchdogStatus, + state: 'checking', + lastCheckAt: new Date().toISOString(), + lastError: undefined, + } + + try { + const current = await readGatewayStatus() + if (current.running) { + watchdogStatus = { + ...watchdogStatus, + state: 'healthy', + lastError: undefined, + } + return + } + + watchdogStatus = { + ...watchdogStatus, + state: 'restarting', + restartCount: watchdogStatus.restartCount + 1, + } + console.warn('ClawMaster gateway safeguard detected OpenClaw gateway downtime; restarting.') + await spawnOpenclawGatewayStart() + + const after = await waitForGatewayRunning() + if (!after.running) { + throw new Error(`Gateway restart completed but port ${after.port} is still unreachable`) + } + + watchdogStatus = { + ...watchdogStatus, + state: 'healthy', + lastRestartAt: new Date().toISOString(), + lastError: undefined, + } + console.log('ClawMaster gateway safeguard restarted OpenClaw gateway.') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + watchdogStatus = { + ...watchdogStatus, + state: 'error', + lastError: message, + } + console.warn(`ClawMaster gateway safeguard failed: ${message}`) + } finally { + watchdogCheckInFlight = false + } +} + +export function getGatewayWatchdogStatus(): GatewayWatchdogStatus { + return snapshotWatchdogStatus() +} + +export function startGatewayWatchdog(options: { intervalMs?: number; runImmediately?: boolean } = {}): GatewayWatchdogStatus { + const intervalMs = options.intervalMs ?? resolveGatewayWatchdogIntervalMs() + watchdogPaused = false + watchdogStatus = { + ...watchdogStatus, + enabled: true, + state: 'idle', + intervalMs, + } + + if (watchdogTimer) { + clearInterval(watchdogTimer) + } + watchdogTimer = setInterval(() => { + void runGatewayWatchdogCheck() + }, intervalMs) + watchdogTimer.unref?.() + + if (options.runImmediately !== false) { + void runGatewayWatchdogCheck() + } + return snapshotWatchdogStatus() +} + +export function stopGatewayWatchdog(): GatewayWatchdogStatus { + if (watchdogTimer) { + clearInterval(watchdogTimer) + watchdogTimer = null + } + watchdogPaused = false + watchdogCheckInFlight = false + watchdogStatus = { + ...watchdogStatus, + enabled: false, + state: 'disabled', + } + return snapshotWatchdogStatus() +} + +export async function getGatewayStatus() { + return { + ...(await readGatewayStatus()), + watchdog: getGatewayWatchdogStatus(), + } +} + export async function startGateway() { await spawnOpenclawGatewayStart() + watchdogPaused = false + if (watchdogStatus.enabled) { + watchdogStatus = { + ...watchdogStatus, + state: 'healthy', + lastError: undefined, + } + } } export async function stopGateway() { - await runOpenclawGatewayStop() + const wasWatchdogEnabled = watchdogStatus.enabled + if (wasWatchdogEnabled) { + watchdogPaused = true + watchdogStatus = { + ...watchdogStatus, + state: 'paused', + lastError: undefined, + } + } + try { + await runOpenclawGatewayStop() + } catch (error) { + if (wasWatchdogEnabled) { + watchdogPaused = false + watchdogStatus = { + ...watchdogStatus, + state: 'error', + lastError: error instanceof Error ? error.message : String(error), + } + } + throw error + } } export async function restartGateway() { await runOpenclawGatewayRestart() + watchdogPaused = false + if (watchdogStatus.enabled) { + watchdogStatus = { + ...watchdogStatus, + state: 'healthy', + lastError: undefined, + } + } } diff --git a/packages/backend/src/services/managedMemoryBridge.ts b/packages/backend/src/services/managedMemoryBridge.ts index 44cd6b07..179b6044 100644 --- a/packages/backend/src/services/managedMemoryBridge.ts +++ b/packages/backend/src/services/managedMemoryBridge.ts @@ -518,7 +518,19 @@ export async function syncManagedMemoryBridge( }) } if (!installed || pathIssue) { - await openclawPlugins.installOpenclawPluginFromPath(paths.runtimePluginPath, { link: true }) + await openclawPlugins.installOpenclawPluginFromPath(paths.runtimePluginPath, { + link: true, + // memory-clawmaster-powermem imports from openclaw/plugin-sdk directly and + // uses resetManagedMemory — a privileged API that OpenClaw's generic + // installer considers "unsafe" because it can irrecoverably clear a profile's + // managed memory store. As the ClawMaster-managed bridge this is intentional; + // ClawMaster owns the lifecycle of this plugin installation. + // + // If a future OpenClaw release adds a narrower exemption mechanism (e.g. a + // trusted-installer allowlist keyed by package name), switch to that instead + // of keeping this override. + dangerouslyForceUnsafeInstall: true, + }) } await updateConfigJson((config) => { setConfigAtPath(config, `plugins.slots.${MEMORY_BRIDGE_SLOT_KEY}`, MEMORY_BRIDGE_PLUGIN_ID) diff --git a/packages/backend/src/services/npmService.ts b/packages/backend/src/services/npmService.ts index 81e08f6b..adf70acd 100644 --- a/packages/backend/src/services/npmService.ts +++ b/packages/backend/src/services/npmService.ts @@ -1,11 +1,15 @@ import { bootstrapOpenclawAfterInstall } from '../openclawBootstrap.js' -import { fetchOpenclawNpmMeta, npmInstallOpenclawFromLocalFile, npmInstallOpenclawGlobal } from '../npmOpenclaw.js' +import { fetchClawmasterNpmMeta, fetchOpenclawNpmMeta, npmInstallOpenclawFromLocalFile, npmInstallOpenclawGlobal } from '../npmOpenclaw.js' import { reinstallOpenclawWithBackup, runReinstallBackupStep, runReinstallUninstallStep } from '../reinstallOpenclaw.js' export async function getNpmOpenclawVersions() { return fetchOpenclawNpmMeta() } +export async function getNpmClawmasterVersions() { + return fetchClawmasterNpmMeta() +} + export async function installOpenclaw(params: { version?: unknown; localPath?: unknown }) { const localPath = typeof params.localPath === 'string' ? params.localPath.trim() : '' if (localPath) return npmInstallOpenclawFromLocalFile(localPath) diff --git a/packages/backend/src/services/openclawPlugins.test.ts b/packages/backend/src/services/openclawPlugins.test.ts index 2e15664f..ac3e67c8 100644 --- a/packages/backend/src/services/openclawPlugins.test.ts +++ b/packages/backend/src/services/openclawPlugins.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { parsePluginsJsonString, parsePluginsPlainText } from './openclawPlugins.js' +import { + buildInstallOpenclawPluginFromPathArgsForTest, + parsePluginsJsonString, + parsePluginsPlainText, +} from './openclawPlugins.js' test('parsePluginsJsonString tolerates plugin log preambles before the JSON payload', () => { const raw = `[plugins] memory-clawmaster-powermem: plugin registered (dataRoot: /Users/haili/.clawmaster/data/default, user: openclaw-user, agent: openclaw-agent) @@ -103,3 +107,24 @@ test('parsePluginsPlainText reads unfenced five-column plugin tables', () => { ) assert.equal(rows[0]?.version, '0.1.0') }) + +test('buildInstallOpenclawPluginFromPathArgsForTest enables forced unsafe installs only when requested', () => { + assert.deepEqual( + buildInstallOpenclawPluginFromPathArgsForTest('/tmp/plugin'), + ['plugins', 'install', '-l', '/tmp/plugin'], + ) + assert.deepEqual( + buildInstallOpenclawPluginFromPathArgsForTest('/tmp/plugin', { + link: true, + dangerouslyForceUnsafeInstall: true, + }), + ['plugins', 'install', '-l', '--dangerously-force-unsafe-install', '/tmp/plugin'], + ) + assert.deepEqual( + buildInstallOpenclawPluginFromPathArgsForTest('/tmp/plugin', { + link: false, + dangerouslyForceUnsafeInstall: true, + }), + ['plugins', 'install', '--dangerously-force-unsafe-install', '/tmp/plugin'], + ) +}) diff --git a/packages/backend/src/services/openclawPlugins.ts b/packages/backend/src/services/openclawPlugins.ts index 819812d8..4d4f10b2 100644 --- a/packages/backend/src/services/openclawPlugins.ts +++ b/packages/backend/src/services/openclawPlugins.ts @@ -573,10 +573,10 @@ export async function installOpenclawPlugin(id: string): Promise { } } -export async function installOpenclawPluginFromPath( +export function buildInstallOpenclawPluginFromPathArgsForTest( pluginPath: string, - options?: { link?: boolean } -): Promise { + options?: { link?: boolean; dangerouslyForceUnsafeInstall?: boolean } +): string[] { const normalized = pluginPath.trim() if (!normalized) { throw new Error('Plugin path is required') @@ -585,7 +585,18 @@ export async function installOpenclawPluginFromPath( if (options?.link !== false) { args.push('-l') } + if (options?.dangerouslyForceUnsafeInstall === true) { + args.push('--dangerously-force-unsafe-install') + } args.push(normalized) + return args +} + +export async function installOpenclawPluginFromPath( + pluginPath: string, + options?: { link?: boolean; dangerouslyForceUnsafeInstall?: boolean } +): Promise { + const args = buildInstallOpenclawPluginFromPathArgsForTest(pluginPath, options) const r = await execOpenclaw(args, PLUGIN_INSTALL_CLI_OPTS) if (r.code !== 0) { throw new Error( diff --git a/packages/backend/src/services/packageDownloadTrackerSkill.test.ts b/packages/backend/src/services/packageDownloadTrackerSkill.test.ts new file mode 100644 index 00000000..4f973837 --- /dev/null +++ b/packages/backend/src/services/packageDownloadTrackerSkill.test.ts @@ -0,0 +1,573 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + analyzePackage, + buildMemoryContent, + buildMemorySearchQuery, + normalizePackageName, + parseArgs, + summarizeResults, + trackDownloads, +} from '../../../../bundled-skills/package-download-tracker/scripts/common.mjs' + +function mockResponse(payload: unknown) { + return { + ok: true, + status: 200, + async json() { + return payload + }, + } +} + +function npmPayload(downloads: number[]) { + return { + downloads: downloads.map((value, index) => ({ + day: `2026-04-${String(index + 1).padStart(2, '0')}`, + downloads: value, + })), + } +} + +test('package-download-tracker parses repeatable package flags and scoped npm names', () => { + const parsed = parseArgs([ + '--registry', + 'npm', + '--package', + '@types/node', + '--packages', + 'react, vite', + '--period', + 'month', + '--load-memory', + '--save-memory', + '--history-limit', + '8', + ]) + + assert.equal(parsed.registry, 'npm') + assert.equal(parsed.period, 'month') + assert.deepEqual(parsed.packages, ['@types/node', 'react', 'vite']) + assert.equal(parsed.loadMemory, true) + assert.equal(parsed.saveMemory, true) + assert.equal(parsed.historyLimit, 8) +}) + +test('package-download-tracker validates PyPI package names', () => { + assert.equal(normalizePackageName('pypi', 'fastapi'), 'fastapi') + assert.equal(normalizePackageName('pypi', 'google-cloud-storage'), 'google-cloud-storage') + assert.equal(normalizePackageName('pypi', 'Power_Mem'), 'power-mem') + assert.equal(normalizePackageName('pypi', 'google.cloud_storage'), 'google-cloud-storage') + assert.throws(() => normalizePackageName('pypi', '../secret'), /Invalid PyPI package name/) +}) + +test('package-download-tracker builds stable PowerMem content and search query', () => { + const result = { + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-28T00:00:00.000Z', + totals: { downloads: 700, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'up 16.7% versus previous observation' }, + warnings: [], + } + + assert.equal( + buildMemorySearchQuery('npm', 'react', 'week'), + 'package-download-tracker registry:npm package:react period:week', + ) + const content = buildMemoryContent(result) + assert.match(content, /package-download-tracker registry:npm package:react period:week/) + assert.match(content, /snapshot-json: \{"registry":"npm","packageName":"react"/) +}) + +test('package-download-tracker reuses previous memory before fetching only the current window', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const calls: string[] = [] + const memoryContent = buildMemoryContent({ + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 600, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const runOpenclaw = (args: string[]) => { + calls.push(`openclaw ${args.join(' ')}`) + assert.deepEqual(args, [ + 'ltm', + 'search', + '--json', + '--query', + 'package-download-tracker registry:npm package:react period:week', + ]) + return JSON.stringify([{ content: memoryContent }]) + } + + const fetchUrls: string[] = [] + const fetchImpl = async (url: string) => { + fetchUrls.push(url) + return mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + } + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'react', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(calls.length, 1) + assert.equal(fetchUrls.length, 1) + assert.equal(fetchUrls[0], 'https://api.npmjs.org/downloads/range/last-week/react') + assert.equal(result.priorMemory.totalDownloads, 600) + assert.equal(result.trend.basis, 'history') + assert.equal(result.history.length, 2) + assert.deepEqual( + result.periodColumns.map((column) => [column.label, column.downloads]), + [['Current week', 700], ['Previous week', 600]], + ) + assert.match(result.trend.text, /previous observation/) + assert.equal(result.totals.downloads, 700) +}) + +test('package-download-tracker builds trend analysis across multiple historical observations', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const snapshots = [ + buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-07T00:00:00.000Z', + totals: { downloads: 300, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'older' }, + warnings: [], + }), + buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-14T00:00:00.000Z', + totals: { downloads: 450, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'middle' }, + warnings: [], + }), + buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 600, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'newer' }, + warnings: [], + }), + ] + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const runOpenclaw = () => JSON.stringify(snapshots.map((content) => ({ content }))) + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + cachePath, + loadMemory: true, + historyLimit: 4, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.trend.basis, 'history') + assert.equal(result.history.length, 4) + assert.deepEqual( + result.history.map((point) => point.downloads), + [300, 450, 600, 700], + ) + assert.match(result.trend.text, /up 16\.7% versus previous observation/) + assert.equal(result.trend.overallDelta?.direction, 'up') + assert.match(result.trend.overallDelta?.text ?? '', /up 133\.3% across 4 observations/) +}) + +test('package-download-tracker keeps one history observation per day', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const olderSameDay = buildMemoryContent({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 400, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'older same day' }, + warnings: [], + }) + const newerSameDay = buildMemoryContent({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + fetchedAt: '2026-04-21T12:00:00.000Z', + totals: { downloads: 500, days: 7 }, + trend: { direction: 'up', basis: 'history', text: 'newer same day' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const runOpenclaw = () => JSON.stringify([ + { content: olderSameDay }, + { content: newerSameDay }, + ]) + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.deepEqual( + result.history.map((point) => point.downloads), + [500, 700], + ) + assert.match(result.trend.text, /up 40\.0% versus previous observation/) +}) + +test('package-download-tracker tolerates PowerMem log preambles before JSON search results', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const memoryContent = buildMemoryContent({ + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 500, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const runOpenclaw = () => `[plugins] memory-clawmaster-powermem loaded\n${JSON.stringify([{ content: memoryContent }])}\n` + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'react', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.totalDownloads, 500) + assert.equal(result.trend.basis, 'history') + assert.deepEqual(result.warnings, []) +}) + +test('package-download-tracker ignores PowerMem snapshots for other packages', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const wrongMemory = buildMemoryContent({ + registry: 'npm', + packageName: 'react', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 131_000_000, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'wrong package' }, + warnings: [], + }) + const rightMemory = buildMemoryContent({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + fetchedAt: '2026-04-20T00:00:00.000Z', + totals: { downloads: 400, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'right package' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([67, 67, 67, 67, 67, 67, 67])) + const runOpenclaw = () => JSON.stringify([ + { content: wrongMemory }, + { content: rightMemory }, + ]) + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.packageName, 'clawmaster') + assert.equal(result.priorMemory.totalDownloads, 400) + assert.equal(result.trend.basis, 'history') + assert.match(result.trend.text, /up 17\.3%/) + assert.deepEqual(result.warnings, []) + assert.equal(result.diagnostics.memory.ignoredSnapshots, 1) + assert.doesNotMatch(result.trend.text, /131/) +}) + +test('package-download-tracker uses the newest exact PowerMem snapshot', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const olderMemory = buildMemoryContent({ + registry: 'pypi', + packageName: 'PowerMem', + period: 'week', + fetchedAt: '2026-04-20T00:00:00.000Z', + totals: { downloads: 700, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'older' }, + warnings: [], + }) + const newerMemory = buildMemoryContent({ + registry: 'pypi', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-27T00:00:00.000Z', + totals: { downloads: 900, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'newer' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse({ + data: Array.from({ length: 14 }, (_, index) => ({ + date: `2026-04-${String(index + 1).padStart(2, '0')}`, + downloads: index < 7 ? 100 : 130, + })), + }) + const recentFetchImpl = async (url: string) => { + if (String(url).includes('/recent')) { + return mockResponse({ data: { last_week: 910 } }) + } + return fetchImpl() + } + const runOpenclaw = () => JSON.stringify([ + { content: olderMemory }, + { content: newerMemory }, + ]) + + const result = await analyzePackage({ + registry: 'pypi', + packageName: 'powermem', + period: 'week', + cachePath, + loadMemory: true, + fetchImpl: recentFetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.fetchedAt, '2026-04-27T00:00:00.000Z') + assert.equal(result.priorMemory.totalDownloads, 900) + assert.deepEqual( + result.periodColumns.map((column) => [column.label, column.downloads]), + [['Current week', 910], ['Previous week', 900]], + ) + assert.match(result.trend.text, /up 1\.1%/) +}) + +test('package-download-tracker canonicalizes PyPI names for cache and memory reuse', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const memoryContent = buildMemoryContent({ + registry: 'pypi', + packageName: 'power-mem', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 900, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const fetchUrls: string[] = [] + const fetchImpl = async (url: string) => { + fetchUrls.push(url) + if (String(url).includes('/recent')) { + return mockResponse({ data: { last_week: 1_000 } }) + } + return mockResponse({ + data: Array.from({ length: 7 }, (_, index) => ({ + date: `2026-04-${String(index + 1).padStart(2, '0')}`, + downloads: 100, + })), + }) + } + const runOpenclaw = (args: string[]) => { + assert.deepEqual(args, [ + 'ltm', + 'search', + '--json', + '--query', + 'package-download-tracker registry:pypi package:power-mem period:week', + ]) + return JSON.stringify([{ content: memoryContent }]) + } + + const parsed = parseArgs(['--registry', 'pypi', '--package', 'Power_Mem', '--period', 'week']) + assert.deepEqual(parsed.packages, ['power-mem']) + + const result = await analyzePackage({ + registry: 'pypi', + packageName: parsed.packages[0]!, + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(result.priorMemory.packageName, 'power-mem') + assert.equal(result.periodColumns[1]?.downloads, 900) + assert.match(result.trend.text, /up 11\.1%/) + assert.ok(fetchUrls.every((url) => String(url).includes('/power-mem/'))) +}) + +test('package-download-tracker summary renders current and previous period columns', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const memoryContent = buildMemoryContent({ + registry: 'npm', + packageName: 'powermem', + period: 'week', + fetchedAt: '2026-04-21T00:00:00.000Z', + totals: { downloads: 600, days: 7 }, + trend: { direction: 'flat', basis: 'current-window', text: 'prior trend' }, + warnings: [], + }) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const payload = await trackDownloads({ + registry: 'npm', + packages: ['powermem'], + period: 'week', + cachePath, + loadMemory: true, + fetchImpl, + runOpenclaw: () => JSON.stringify([{ content: memoryContent }]), + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + const summary = summarizeResults(payload) + assert.match(summary, /\| Package \| Current week \| Previous week \| Trend \|/) + assert.match(summary, /\| powermem \| 700 \| 600 \| up 16\.7% versus previous observation/) +}) + +test('package-download-tracker summary does not show percentage changes without a previous period value', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const fetchImpl = async () => mockResponse(npmPayload([100, 100, 100, 100, 100, 100, 100])) + const payload = await trackDownloads({ + registry: 'npm', + packages: ['clawmaster'], + period: 'week', + cachePath, + fetchImpl, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + payload.results[0]!.trend.text = 'down -22.0% versus previous week' + + const summary = summarizeResults(payload) + assert.match(summary, /\| clawmaster \| 700 \| n\/a \| No previous week data available\. \|/) + assert.doesNotMatch(summary, /-22\.0%/) +}) + +test('package-download-tracker keeps PowerMem save failures out of user-facing warnings', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + const fetchImpl = async () => mockResponse(npmPayload([10, 10, 10, 10, 10, 10, 10])) + const runOpenclaw = (args: string[]) => { + if (args[0] === 'ltm' && args[1] === 'search') return JSON.stringify([]) + throw new Error('PowerMem offline') + } + + const result = await analyzePackage({ + registry: 'npm', + packageName: 'clawmaster', + period: 'week', + cachePath, + loadMemory: true, + saveMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.deepEqual(result.warnings, []) + assert.match(result.diagnostics.memory.saveWarning ?? '', /PowerMem save unavailable/) +}) + +test('package-download-tracker uses cache for repeat requests', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + let fetchCount = 0 + const fetchImpl = async () => { + fetchCount += 1 + return mockResponse(npmPayload([10, 20, 30, 40, 50, 60, 70])) + } + + const first = await trackDownloads({ + registry: 'npm', + packages: ['react'], + period: 'week', + cachePath, + fetchImpl, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + const second = await trackDownloads({ + registry: 'npm', + packages: ['react'], + period: 'week', + cachePath, + fetchImpl, + now: () => new Date('2026-04-28T00:00:00.000Z'), + }) + + assert.equal(fetchCount, 1) + assert.equal(first.results[0]?.cache.fromCache, false) + assert.equal(second.results[0]?.cache.fromCache, true) + assert.equal(second.results[0]?.totals.downloads, 280) +}) + +test('package-download-tracker bypasses cache when saving a fresh memory observation', async () => { + const cachePath = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmaster-package-download-cache-')) + let fetchCount = 0 + const fetchImpl = async () => { + fetchCount += 1 + return mockResponse(npmPayload(fetchCount === 1 + ? [10, 10, 10, 10, 10, 10, 10] + : [20, 20, 20, 20, 20, 20, 20])) + } + const runOpenclaw = (args: string[]) => { + if (args[0] === 'ltm' && args[1] === 'search') return JSON.stringify([]) + if (args[0] === 'ltm' && args[1] === 'add') return JSON.stringify({ ok: true }) + throw new Error(`unexpected openclaw args: ${args.join(' ')}`) + } + + const first = await trackDownloads({ + registry: 'npm', + packages: ['clawmaster'], + period: 'week', + cachePath, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-28T08:01:00.000Z'), + }) + const second = await trackDownloads({ + registry: 'npm', + packages: ['clawmaster'], + period: 'week', + cachePath, + loadMemory: true, + saveMemory: true, + fetchImpl, + runOpenclaw, + now: () => new Date('2026-04-29T07:59:00.000Z'), + }) + + assert.equal(fetchCount, 2) + assert.equal(first.results[0]?.cache.fromCache, false) + assert.equal(second.results[0]?.cache.fromCache, false) + assert.equal(second.results[0]?.totals.downloads, 140) +}) diff --git a/packages/backend/src/services/providerCatalogService.test.ts b/packages/backend/src/services/providerCatalogService.test.ts index dbabe870..37dc62c7 100644 --- a/packages/backend/src/services/providerCatalogService.test.ts +++ b/packages/backend/src/services/providerCatalogService.test.ts @@ -64,3 +64,79 @@ test('listProviderModels normalizes custom OpenAI-compatible chat completions ba globalThis.fetch = originalFetch } }) + +test('listProviderModels uses the native Z.AI GLM catalog endpoint', async () => { + const originalFetch = globalThis.fetch + let requestedUrl = '' + let requestedAuthorization = '' + + globalThis.fetch = async (input, init) => { + requestedUrl = String(input) + requestedAuthorization = String((init?.headers as Record | undefined)?.Authorization ?? '') + return new Response(JSON.stringify({ + data: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'embedding-3', name: 'Embedding' }, + ], + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + try { + const result = await listProviderModels({ + providerId: 'zai', + apiKey: 'zai-key', + }) + + assert.equal(requestedUrl, 'https://api.z.ai/api/paas/v4/models') + assert.equal(requestedAuthorization, 'Bearer zai-key') + assert.deepEqual(result, [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + ]) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('listProviderModels uses the Baidu BCE Qianfan coding catalog endpoint', async () => { + const originalFetch = globalThis.fetch + let requestedUrl = '' + let requestedAuthorization = '' + + globalThis.fetch = async (input, init) => { + requestedUrl = String(input) + requestedAuthorization = String((init?.headers as Record | undefined)?.Authorization ?? '') + return new Response(JSON.stringify({ + data: [ + { id: 'ernie-4.5-turbo-128k', name: 'ERNIE 4.5 Turbo' }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B' }, + { id: 'qwen3-embedding-4b', name: 'Qwen3 Embedding' }, + ], + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + try { + const result = await listProviderModels({ + providerId: 'baiduqianfancodingplan', + apiKey: 'bce-key', + }) + + assert.equal(requestedUrl, 'https://qianfan.baidubce.com/v2/coding/models') + assert.equal(requestedAuthorization, 'Bearer bce-key') + assert.deepEqual(result, [ + { id: 'qianfan-code-latest', name: 'Qianfan Code Latest' }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B' }, + ]) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/packages/backend/src/services/providerCatalogService.ts b/packages/backend/src/services/providerCatalogService.ts index b93fe07c..115f7ef3 100644 --- a/packages/backend/src/services/providerCatalogService.ts +++ b/packages/backend/src/services/providerCatalogService.ts @@ -23,6 +23,8 @@ const OPENAI_COMPATIBLE_PROVIDER_DEFAULTS: Record = { 'kimi-coding': 'https://api.moonshot.cn/v1', siliconflow: 'https://api.siliconflow.cn/v1', 'baidu-aistudio': 'https://aistudio.baidu.com/llm/lmapi/v3', + baiduqianfancodingplan: 'https://qianfan.baidubce.com/v2/coding', + zai: 'https://api.z.ai/api/paas/v4', openrouter: 'https://openrouter.ai/api/v1', cerebras: 'https://api.cerebras.ai/v1', } @@ -189,6 +191,18 @@ function filterProviderCatalogModels(providerId: string, models: ProviderCatalog return models.filter((model) => !/(embed|moderation)/i.test(model.id)) case 'baidu-aistudio': return models.filter((model) => !/(embedding|bge|stable-diffusion|infer-|sft-)/i.test(model.id)) + case 'baiduqianfancodingplan': { + const codingModels = models.filter((model) => + /(?:qianfan-code|coder|codellama|sqlcoder)/i.test(model.id) && + !/(embedding|bge|image|ocr|stable-diffusion|wan-|vl)/i.test(model.id), + ) + if (!codingModels.some((model) => model.id === 'qianfan-code-latest')) { + codingModels.unshift({ id: 'qianfan-code-latest', name: 'Qianfan Code Latest' }) + } + return codingModels + } + case 'zai': + return models.filter((model) => /^glm-/i.test(model.id) && !/(embedding|image|ocr)/i.test(model.id)) default: return models } diff --git a/packages/backend/src/services/wikiLlm.test.ts b/packages/backend/src/services/wikiLlm.test.ts new file mode 100644 index 00000000..80aa3392 --- /dev/null +++ b/packages/backend/src/services/wikiLlm.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + resolveWikiLlm, + setWikiLlmCommandRunnerForTests, + setWikiLlmTransportForTests, + setWikiLlmUseGatewayFetchForTests, + wikiLlmComplete, + wikiLlmEnabled, +} from './wikiLlm.js' + +const originalFetch = globalThis.fetch +const originalUseGatewayEnv = process.env.WIKI_LLM_USE_GATEWAY + +async function createHomeRoot(name: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `clawmaster-wiki-llm-${name}-`)) +} + +async function writeConfig(homeDir: string, config: Record): Promise { + const configDir = path.join(homeDir, '.openclaw') + await fs.mkdir(configDir, { recursive: true }) + await fs.writeFile(path.join(configDir, 'openclaw.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8') +} + +test.afterEach(() => { + globalThis.fetch = originalFetch + setWikiLlmCommandRunnerForTests(null) + setWikiLlmTransportForTests(null) + if (originalUseGatewayEnv === undefined) delete process.env.WIKI_LLM_USE_GATEWAY + else process.env.WIKI_LLM_USE_GATEWAY = originalUseGatewayEnv +}) + +test('resolveWikiLlm disables the helper when no default model is configured', async () => { + const homeDir = await createHomeRoot('disabled') + await writeConfig(homeDir, { gateway: { port: 18888 } }) + const resolved = resolveWikiLlm({ homeDir }) + assert.equal(resolved.enabled, false) + assert.match(String(resolved.disabledReason), /default model/i) + assert.equal(wikiLlmEnabled({ homeDir }), false) +}) + +test('resolveWikiLlm reads gateway auth and default model from openclaw.json', async () => { + const homeDir = await createHomeRoot('config') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + gateway: { bind: '0.0.0.0', port: 19999, auth: { mode: 'token', token: 'abc123' } }, + maxWikiLlmTokensPerOperation: 2048, + }) + const resolved = resolveWikiLlm({ homeDir }) + assert.equal(resolved.enabled, true) + assert.equal(resolved.model, 'openai/gpt-4o-mini') + assert.equal(resolved.gatewayUrl, 'http://127.0.0.1:19999') + assert.equal(resolved.authToken, 'abc123') + assert.equal(resolved.maxTokensPerOperation, 2048) +}) + +test('wikiLlmComplete clamps max tokens to the configured cap', async () => { + const homeDir = await createHomeRoot('complete') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + maxWikiLlmTokensPerOperation: 512, + }) + + setWikiLlmUseGatewayFetchForTests(true) + let requestedMaxTokens = -1 + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { max_tokens?: number } + requestedMaxTokens = Number(body.max_tokens) + return new Response(JSON.stringify({ + choices: [{ message: { content: 'ok' } }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + const result = await wikiLlmComplete( + [{ role: 'user', content: 'hello' }], + { maxTokens: 4096 }, + { homeDir }, + ) + assert.equal(result, 'ok') + assert.equal(requestedMaxTokens, 512) +}) + +test('wikiLlmComplete uses the supported infer model gateway command in production transport', async () => { + const homeDir = await createHomeRoot('cli') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'siliconflow/Pro/zai-org/GLM-5.1' } } }, + gateway: { bind: 'loopback', port: 18789, auth: { mode: 'token', token: 'abc123' } }, + }) + + let capturedArgs: string[] | null = null + setWikiLlmCommandRunnerForTests(async (args) => { + capturedArgs = args + return { + code: 0, + stdout: JSON.stringify({ + ok: true, + outputs: [{ text: 'OK' }], + }), + stderr: '', + } + }) + + const result = await wikiLlmComplete( + [{ role: 'user', content: 'Reply with exactly OK.' }], + {}, + { homeDir }, + ) + + assert.equal(result, 'OK') + assert.deepEqual(capturedArgs, [ + 'infer', + 'model', + 'run', + '--gateway', + '--json', + '--prompt', + 'USER:\nReply with exactly OK.', + ]) +}) + +test('wikiLlmComplete defaults to infer-model transport and does not touch globalThis.fetch', async () => { + const homeDir = await createHomeRoot('transport-default') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + + let fetchCalled = false + globalThis.fetch = (async () => { + fetchCalled = true + return new Response('{}', { status: 200 }) + }) as typeof fetch + + let inferCalled = false + setWikiLlmCommandRunnerForTests(async () => { + inferCalled = true + return { + code: 0, + stdout: JSON.stringify({ ok: true, outputs: [{ text: 'from-infer' }] }), + stderr: '', + } + }) + + const result = await wikiLlmComplete([{ role: 'user', content: 'ping' }], {}, { homeDir }) + assert.equal(result, 'from-infer') + assert.equal(inferCalled, true) + assert.equal(fetchCalled, false) +}) + +test('wikiLlmComplete uses gateway-fetch when WIKI_LLM_USE_GATEWAY=1', async () => { + const homeDir = await createHomeRoot('transport-env') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + gateway: { port: 19999, auth: { mode: 'token', token: 'env-token' } }, + }) + + process.env.WIKI_LLM_USE_GATEWAY = '1' + let fetchCalled = false + globalThis.fetch = (async () => { + fetchCalled = true + return new Response(JSON.stringify({ + choices: [{ message: { content: 'from-gateway' } }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + + const result = await wikiLlmComplete([{ role: 'user', content: 'ping' }], {}, { homeDir }) + assert.equal(result, 'from-gateway') + assert.equal(fetchCalled, true) +}) + +test('setWikiLlmTransportForTests explicit override beats WIKI_LLM_USE_GATEWAY env', async () => { + const homeDir = await createHomeRoot('transport-override') + await writeConfig(homeDir, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + + process.env.WIKI_LLM_USE_GATEWAY = '1' + setWikiLlmTransportForTests('infer-model') + + let inferCalled = false + setWikiLlmCommandRunnerForTests(async () => { + inferCalled = true + return { code: 0, stdout: JSON.stringify({ ok: true, outputs: [{ text: 'infer-wins' }] }), stderr: '' } + }) + + const result = await wikiLlmComplete([{ role: 'user', content: 'ping' }], {}, { homeDir }) + assert.equal(result, 'infer-wins') + assert.equal(inferCalled, true) +}) diff --git a/packages/backend/src/services/wikiLlm.ts b/packages/backend/src/services/wikiLlm.ts new file mode 100644 index 00000000..034e308d --- /dev/null +++ b/packages/backend/src/services/wikiLlm.ts @@ -0,0 +1,329 @@ +import fs from 'node:fs' +import type { OpenclawProfileContext, OpenclawProfileSelection } from '../openclawProfile.js' +import { getOpenclawConfigResolution } from '../paths.js' +import { execOpenclaw, extractFirstJsonObject } from '../execOpenclaw.js' + +export type WikiLlmRole = 'system' | 'user' | 'assistant' + +export interface WikiLlmMessage { + role: WikiLlmRole + content: string +} + +export interface WikiLlmOptions { + maxTokens?: number + temperature?: number + signal?: AbortSignal +} + +export interface WikiLlmResolution { + enabled: boolean + disabledReason?: string + model?: string + gatewayUrl: string + authToken?: string + maxTokensPerOperation: number +} + +export interface WikiLlmContext extends OpenclawProfileContext { + profileSelection?: OpenclawProfileSelection +} + +export type WikiLlmTransport = 'infer-model' | 'gateway-fetch' + +type GatewayChatCompletionResponse = { + choices?: Array<{ + message?: { + content?: unknown + } + }> +} + +type InferModelRunResponse = { + ok?: boolean + outputs?: Array<{ + text?: unknown + }> + error?: unknown +} + +const DEFAULT_GATEWAY_PORT = 18789 +const DEFAULT_MAX_WIKI_LLM_TOKENS = 4096 +const DEFAULT_WIKI_LLM_TIMEOUT_MS = 120_000 + +let wikiLlmCommandRunnerOverride: typeof execOpenclaw | null = null +let wikiLlmTransportOverride: WikiLlmTransport | null = null + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizeGatewayHost(bind: string | undefined): string { + const value = bind?.trim() + if (!value || value === 'loopback' || value === '0.0.0.0') return '127.0.0.1' + if (value === '::' || value === '[::]') return '[::1]' + if (value.includes(':') && !value.startsWith('[') && !value.endsWith(']')) return `[${value}]` + return value +} + +function numberFromUnknown(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) + ? value + : typeof value === 'string' && Number.isFinite(Number(value)) + ? Number(value) + : fallback +} + +function stringPath(root: Record, path: string[]): string | undefined { + let current: unknown = root + for (const key of path) { + if (!isRecord(current)) return undefined + current = current[key] + } + return typeof current === 'string' && current.trim() ? current.trim() : undefined +} + +function readConfigForContext(context: WikiLlmContext = {}): Record { + const resolution = getOpenclawConfigResolution({ + homeDir: context.homeDir, + platform: context.platform, + profileSelection: context.profileSelection, + }) + if (!fs.existsSync(resolution.configPath)) return {} + try { + const parsed = JSON.parse(fs.readFileSync(resolution.configPath, 'utf8')) as unknown + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +function extractContent(raw: unknown): string { + if (typeof raw === 'string') return raw + if (Array.isArray(raw)) { + return raw + .map((item) => { + if (typeof item === 'string') return item + if (isRecord(item) && typeof item.text === 'string') return item.text + return '' + }) + .filter(Boolean) + .join('\n') + } + return '' +} + +function clampMaxTokens(requested: number | undefined, cap: number): number { + const normalized = numberFromUnknown(requested, Math.min(1024, cap)) + return Math.max(64, Math.min(cap, Math.round(normalized))) +} + +function appendPathSuffix(baseUrl: string, suffix: string): string { + const normalizedBase = baseUrl.replace(/\/+$/, '') + const normalizedSuffix = suffix.startsWith('/') ? suffix : `/${suffix}` + return `${normalizedBase}${normalizedSuffix}` +} + +function buildWikiLlmPrompt(messages: WikiLlmMessage[]): string { + return messages + .map((message) => `${message.role.toUpperCase()}:\n${message.content.trim()}`) + .join('\n\n') + .trim() +} + +function parseInferModelRunResponse(raw: string): InferModelRunResponse { + const jsonText = extractFirstJsonObject(raw) ?? raw + return JSON.parse(jsonText) as InferModelRunResponse +} + +function extractInferModelText(payload: InferModelRunResponse): string { + const outputs = Array.isArray(payload.outputs) ? payload.outputs : [] + return extractContent(outputs.map((item) => item?.text)).trim() +} + +function resolveTransport(): WikiLlmTransport { + if (wikiLlmTransportOverride !== null) return wikiLlmTransportOverride + const envValue = process.env['WIKI_LLM_USE_GATEWAY'] + if (envValue === '1') return 'gateway-fetch' + return 'infer-model' +} + +function getWikiLlmCommandRunner(): typeof execOpenclaw { + return wikiLlmCommandRunnerOverride ?? execOpenclaw +} + +async function wikiLlmCompleteViaGatewayFetch( + resolved: WikiLlmResolution, + messages: WikiLlmMessage[], + options: WikiLlmOptions, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + } + if (resolved.authToken) { + headers.Authorization = `Bearer ${resolved.authToken}` + } + + const response = await fetch(appendPathSuffix(resolved.gatewayUrl, '/v1/chat/completions'), { + method: 'POST', + headers, + signal: options.signal, + body: JSON.stringify({ + model: resolved.model, + messages, + temperature: options.temperature ?? 0.2, + max_tokens: clampMaxTokens(options.maxTokens, resolved.maxTokensPerOperation), + }), + }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Wiki gateway completion failed (${response.status})${detail ? `: ${detail}` : ''}`) + } + + const payload = await response.json() as GatewayChatCompletionResponse + const content = extractContent(payload.choices?.[0]?.message?.content).trim() + if (!content) throw new Error('Wiki gateway completion returned no content.') + return content +} + +async function wikiLlmCompleteViaInferModel( + resolved: WikiLlmResolution, + messages: WikiLlmMessage[], +): Promise { + const args = ['infer', 'model', 'run', '--gateway', '--json'] + args.push('--prompt', buildWikiLlmPrompt(messages)) + + const result = await getWikiLlmCommandRunner()(args, { timeoutMs: DEFAULT_WIKI_LLM_TIMEOUT_MS }) + if (result.code !== 0) { + const detail = result.stderr || result.stdout || 'OpenClaw infer model run failed' + throw new Error(`Wiki infer model run failed (${result.code}): ${detail}`) + } + + let payload: InferModelRunResponse + try { + payload = parseInferModelRunResponse(result.stdout) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Wiki infer model run returned invalid JSON: ${message}`) + } + + if (payload.ok === false) { + const detail = typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error ?? null) + throw new Error(`Wiki infer model run returned an error: ${detail}`) + } + + const content = extractInferModelText(payload) + if (!content) throw new Error('Wiki infer model run returned no content.') + return content +} + +export function resolveWikiLlm(context: WikiLlmContext = {}): WikiLlmResolution { + const config = readConfigForContext(context) + const model = stringPath(config, ['agents', 'defaults', 'model', 'primary']) + const gateway = isRecord(config.gateway) ? config.gateway : {} + const auth = isRecord(gateway.auth) ? gateway.auth : {} + const rawMaxTokens = + config.maxWikiLlmTokensPerOperation + ?? (isRecord(config.wiki) ? config.wiki.maxWikiLlmTokensPerOperation : undefined) + const maxTokensPerOperation = Math.max( + 256, + Math.min(32_768, Math.round(numberFromUnknown(rawMaxTokens, DEFAULT_MAX_WIKI_LLM_TOKENS))), + ) + const port = Math.max(1, Math.min(65_535, Math.round(numberFromUnknown(gateway.port, DEFAULT_GATEWAY_PORT)))) + const gatewayUrl = `http://${normalizeGatewayHost(typeof gateway.bind === 'string' ? gateway.bind : undefined)}:${port}` + const authMode = typeof auth.mode === 'string' ? auth.mode.trim() : '' + const authToken = authMode === 'token' && typeof auth.token === 'string' && auth.token.trim() + ? auth.token.trim() + : undefined + + if (!model) { + return { + enabled: false, + disabledReason: 'No default model is configured for wiki LLM operations.', + gatewayUrl, + maxTokensPerOperation, + } + } + + return { + enabled: true, + model, + gatewayUrl, + authToken, + maxTokensPerOperation, + } +} + +export function wikiLlmEnabled(context: WikiLlmContext = {}): boolean { + return resolveWikiLlm(context).enabled +} + +export async function wikiLlmComplete( + messages: WikiLlmMessage[], + options: WikiLlmOptions = {}, + context: WikiLlmContext = {}, +): Promise { + const resolved = resolveWikiLlm(context) + if (!resolved.enabled || !resolved.model) { + throw new Error(resolved.disabledReason || 'Wiki LLM is not enabled.') + } + + // An explicit command-runner override (used by unit tests targeting the CLI + // transport) always takes precedence so the runner is actually exercised. + if (wikiLlmCommandRunnerOverride) { + return wikiLlmCompleteViaInferModel(resolved, messages) + } + + if (resolveTransport() === 'gateway-fetch') { + return wikiLlmCompleteViaGatewayFetch(resolved, messages, options) + } + return wikiLlmCompleteViaInferModel(resolved, messages) +} + +export async function wikiLlmCompleteStructured( + messages: WikiLlmMessage[], + schema: unknown, + options: WikiLlmOptions = {}, + context: WikiLlmContext = {}, +): Promise { + const raw = await wikiLlmComplete( + [ + { + role: 'system', + content: `Return only valid JSON that matches this schema shape: ${JSON.stringify(schema)}`, + }, + ...messages, + ], + options, + context, + ) + const jsonText = extractFirstJsonObject(raw) ?? raw + try { + return JSON.parse(jsonText) as T + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Wiki gateway structured completion returned invalid JSON: ${message}`) + } +} + +/** @internal */ +export function setWikiLlmCommandRunnerForTests( + runner: typeof execOpenclaw | null, +): void { + wikiLlmCommandRunnerOverride = runner +} + +/** @internal Force a specific transport for tests. Pass null to restore default resolution. */ +export function setWikiLlmTransportForTests(transport: WikiLlmTransport | null): void { + wikiLlmTransportOverride = transport +} + +/** + * @deprecated use setWikiLlmTransportForTests('gateway-fetch') instead. + * Kept temporarily so existing test files keep compiling during the + * wikiLlm transport refactor. + */ +export function setWikiLlmUseGatewayFetchForTests(flag: boolean): void { + setWikiLlmTransportForTests(flag ? 'gateway-fetch' : null) +} + diff --git a/packages/backend/src/services/wikiService.test.ts b/packages/backend/src/services/wikiService.test.ts new file mode 100644 index 00000000..dd480ac3 --- /dev/null +++ b/packages/backend/src/services/wikiService.test.ts @@ -0,0 +1,971 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { closeManagedMemoryRuntimesForTests } from './managedMemory.js' +import { setWikiLlmTransportForTests, setWikiLlmUseGatewayFetchForTests } from './wikiLlm.js' +import { + assistWithWiki, + classifyWikiQuestion, + ensureWikiVault, + evolveWiki, + evolveWikiDeep, + getWikiPage, + getWikiStatus, + ingestWikiSource, + listWikiPages, + lintWiki, + planWikiLinkChoice, + queryWiki, + resolveWikiPaths, + searchWiki, + synthesizeWiki, + type WikiServiceContext, +} from './wikiService.js' + +const originalFetch = globalThis.fetch + +async function createContext(name: string): Promise { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), `clawmaster-wiki-${name}-`)) + return { + homeDir: tempRoot, + vaultRootOverride: path.join(tempRoot, 'wiki'), + managedMemoryContext: { + dataRootOverride: path.join(tempRoot, 'data'), + profileSelection: { kind: 'default' }, + engineOverride: 'powermem-sqlite', + }, + } +} + +async function writeWikiConfig(context: WikiServiceContext, config: Record): Promise { + const configDir = path.join(context.homeDir!, '.openclaw') + await fs.mkdir(configDir, { recursive: true }) + await fs.writeFile(path.join(configDir, 'openclaw.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8') +} + +test.afterEach(async () => { + await closeManagedMemoryRuntimesForTests() + globalThis.fetch = originalFetch + setWikiLlmTransportForTests(null) +}) + +test('ensureWikiVault creates the expected wiki structure', async () => { + const context = await createContext('vault') + const paths = await ensureWikiVault(context) + + await assert.doesNotReject(fs.stat(paths.rawRoot)) + await assert.doesNotReject(fs.stat(path.join(paths.pagesRoot, 'sources'))) + await assert.doesNotReject(fs.stat(path.join(paths.pagesRoot, 'entities'))) + await assert.doesNotReject(fs.stat(paths.indexPath)) + await assert.doesNotReject(fs.stat(paths.schemaPath)) + await assert.doesNotReject(fs.stat(paths.freshnessPath)) +}) + +test('resolveWikiPaths derives vault root from managedMemoryContext.dataRootOverride when vaultRootOverride is absent', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'clawmaster-wiki-datarootoverride-')) + const dataRoot = path.join(tempRoot, '.clawmaster', 'data', 'default') + const context: WikiServiceContext = { + homeDir: tempRoot, + managedMemoryContext: { + dataRootOverride: dataRoot, + profileSelection: { kind: 'default' }, + engineOverride: 'powermem-sqlite', + }, + } + const paths = resolveWikiPaths(context) + assert.equal(paths.vaultRoot, path.join(tempRoot, '.openclaw', 'wiki')) +}) + +test('ingest creates a managed memory-backed markdown page and repeat ingest skips unchanged source', async () => { + const context = await createContext('ingest') + const first = await ingestWikiSource( + { + title: 'PowerMem Bridge', + content: 'PowerMem is the managed runtime root. [[SeekDB Runtime]] is preferred when supported.', + sourcePath: '/notes/powermem.md', + }, + context, + ) + + assert.equal(first.state, 'ingested') + assert.equal(first.pagesCreated, 1) + assert.ok(first.memoryId) + assert.ok(first.page?.id) + + const page = await getWikiPage(first.page!.id, context) + assert.equal(page.title, 'PowerMem Bridge') + assert.match(page.content, /managed runtime root/i) + assert.equal(page.memoryIds[0], first.memoryId) + assert.equal(page.sourceCount, 1) + + const repeat = await ingestWikiSource( + { + title: 'PowerMem Bridge', + content: 'PowerMem is the managed runtime root. [[SeekDB Runtime]] is preferred when supported.', + sourcePath: '/notes/powermem.md', + }, + context, + ) + assert.equal(repeat.state, 'skipped') + assert.equal(repeat.pagesCreated, 0) + + const status = await getWikiStatus(context) + assert.equal(status.pageCount, 1) + assert.equal(status.sourceCount, 1) +}) + +test('repeat ingest with a changed title updates the existing source page', async () => { + const context = await createContext('rename') + const first = await ingestWikiSource( + { + title: 'Original Source Title', + content: 'The source records reusable agent runtime context.', + sourcePath: '/notes/same-source.md', + }, + context, + ) + assert.ok(first.page) + + const updated = await ingestWikiSource( + { + title: 'Renamed Source Title', + content: 'The source records reusable agent runtime context with a new title.', + sourcePath: '/notes/same-source.md', + }, + context, + ) + + assert.equal(updated.state, 'updated') + assert.equal(updated.pagesCreated, 0) + assert.equal(updated.pagesUpdated, 1) + assert.equal(updated.page?.id, first.page.id) + + const initialDetail = await getWikiPage(first.page.id, context) + await fs.writeFile( + initialDetail.path, + (await fs.readFile(initialDetail.path, 'utf8')).replace(/createdAt: .+/, 'createdAt: "2000-01-01T00:00:00.000Z"'), + 'utf8', + ) + const secondUpdate = await ingestWikiSource( + { + title: 'Retitled Source Title', + content: 'The source records reusable agent runtime context with a second new title.', + sourcePath: '/notes/same-source.md', + }, + context, + ) + assert.equal(secondUpdate.state, 'updated') + + const pages = await listWikiPages(context) + assert.equal(pages.length, 1) + assert.equal(pages[0]!.id, first.page.id) + assert.equal(pages[0]!.title, 'Retitled Source Title') + + const detail = await getWikiPage(first.page.id, context) + assert.equal(detail.title, 'Retitled Source Title') + assert.equal(detail.createdAt, '2000-01-01T00:00:00.000Z') + assert.match(detail.content, /second new title/) +}) + +test('search and query combine wiki articles with managed PowerMem results', async () => { + const context = await createContext('search') + await ingestWikiSource( + { + title: 'SeekDB Runtime', + content: 'SeekDB provides semantic retrieval for durable wiki knowledge.', + sourcePath: '/notes/seekdb.md', + }, + context, + ) + + const hits = await searchWiki('semantic retrieval', { limit: 5 }, context) + assert.ok(hits.some((hit) => hit.title === 'SeekDB Runtime')) + assert.ok(hits.some((hit) => hit.matchType === 'keyword' || hit.matchType === 'semantic')) + + const answer = await queryWiki('what do we know about semantic retrieval?', { limit: 5 }, context) + assert.equal(answer.usedWiki, true) + assert.match(answer.answer, /\[\[SeekDB Runtime\]\]/) + assert.equal(answer.offerToSave, true) +}) + +test('query uses the gateway-backed wiki llm when enabled', async () => { + const context = await createContext('query-llm') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + gateway: { port: 19191, auth: { mode: 'token', token: 'secret-wiki-token' } }, + }) + await ingestWikiSource( + { + title: 'Gateway Runtime', + content: 'The gateway runtime keeps provider auth and model routing centralized.', + sourcePath: '/notes/gateway-runtime.md', + }, + context, + ) + + let requestedAuthorization = '' + let requestedUrl = '' + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + requestedUrl = String(input) + requestedAuthorization = String(new Headers(init?.headers).get('Authorization') ?? '') + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Gateway answer with [[Gateway Runtime]] citations.' } }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + const result = await queryWiki('what do we know about gateway runtime?', { limit: 5 }, context) + assert.equal(result.answer, 'Gateway answer with [[Gateway Runtime]] citations.') + assert.deepEqual(result.warnings, []) + assert.equal(requestedAuthorization, 'Bearer secret-wiki-token') + assert.equal(requestedUrl, 'http://127.0.0.1:19191/v1/chat/completions') +}) + +test('llm ingest creates derived pages and removes outdated generated pages on re-ingest', async () => { + const context = await createContext('derived-ingest') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + let callCount = 0 + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => { + callCount += 1 + const content = callCount === 1 + ? JSON.stringify({ + items: [ + { + name: 'SeekDB Runtime', + kind: 'entity', + summary: 'SeekDB Runtime is the durable retrieval layer described by this source.', + confidence: 0.94, + }, + ], + }) + : JSON.stringify({ items: [] }) + return new Response(JSON.stringify({ + choices: [{ message: { content } }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + const first = await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem relies on SeekDB Runtime for durable retrieval.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + assert.equal(first.pagesCreated, 2) + const derivedPage = await getWikiPage('entities-seekdb-runtime', context) + assert.equal(derivedPage.type, 'entity') + assert.equal(derivedPage.frontmatter.generatedFromSourceIds, first.page!.id) + assert.match(derivedPage.content, /PowerMem Source/) + + const derivedRaw = await fs.readFile(derivedPage.path, 'utf8') + assert.match( + derivedRaw, + new RegExp(`generatedFromSourceIds: \\["${first.page!.id}"\\]`), + 'derived page should persist generatedFromSourceIds as a YAML array on disk', + ) + + const sourcePage = await getWikiPage(first.page!.id, context) + assert.equal(sourcePage.frontmatter.relatedPages, 'SeekDB Runtime') + const sourceRaw = await fs.readFile(sourcePage.path, 'utf8') + assert.match( + sourceRaw, + /relatedPages: \["SeekDB Runtime"\]/, + 'source page should persist relatedPages as a YAML array on disk', + ) + assert.doesNotMatch(sourceRaw, /## Extracted Wiki Links/) + + const second = await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem no longer references a separate durable retrieval layer.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + assert.equal(second.state, 'updated') + const pages = await listWikiPages(context) + assert.ok(!pages.some((page) => page.id === 'entities-seekdb-runtime')) +}) + +test('multi-item relatedPages and generatedFromSourceIds persist as YAML arrays on disk', async () => { + const context = await createContext('multi-array-frontmatter') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + items: [ + { name: 'Entity Alpha', kind: 'entity', summary: 'First extracted entity.', confidence: 0.95 }, + { name: 'Concept Beta', kind: 'concept', summary: 'Second extracted concept.', confidence: 0.90 }, + ], + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) as typeof fetch + + const result = await ingestWikiSource( + { title: 'Multi Source', content: 'Describes Entity Alpha and Concept Beta.', sourcePath: '/notes/multi.md' }, + context, + ) + + const sourcePage = await getWikiPage(result.page!.id, context) + const sourceRaw = await fs.readFile(sourcePage.path, 'utf8') + assert.match( + sourceRaw, + /relatedPages: \["Entity Alpha", "Concept Beta"\]/, + 'source page relatedPages should be a two-element YAML array on disk', + ) + assert.equal( + sourcePage.frontmatter.relatedPages, + 'Entity Alpha|Concept Beta', + 'in-memory relatedPages should be pipe-joined for backward-compat', + ) + + const derivedAlpha = await getWikiPage('entities-entity-alpha', context) + const alphaRaw = await fs.readFile(derivedAlpha.path, 'utf8') + assert.match( + alphaRaw, + /generatedFromSourceIds: \["sources-multi-source"\]/, + 'derived entity page generatedFromSourceIds should be a single-element YAML array on disk', + ) + + const derivedBeta = await getWikiPage('concepts-concept-beta', context) + const betaRaw = await fs.readFile(derivedBeta.path, 'utf8') + assert.match( + betaRaw, + /generatedFromSourceIds: \["sources-multi-source"\]/, + ) +}) + +test('re-ingest preserves generated derived pages when extraction is unavailable and cleans them after recovery', async () => { + const context = await createContext('derived-ingest-disabled') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + let fetchMode: 'derived' | 'empty' = 'derived' + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: fetchMode === 'derived' + ? JSON.stringify({ + items: [ + { + name: 'SeekDB Runtime', + kind: 'entity', + summary: 'SeekDB Runtime is the durable retrieval layer described by this source.', + confidence: 0.94, + }, + ], + }) + : JSON.stringify({ items: [] }), + }, + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch + + await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem relies on SeekDB Runtime for durable retrieval.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + await writeWikiConfig(context, {}) + await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem still has updated source text while the gateway is unavailable.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + + const preserved = await getWikiPage('entities-seekdb-runtime', context) + assert.equal(preserved.type, 'entity') + assert.match(preserved.content, /durable retrieval layer/) + + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + fetchMode = 'empty' + await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem no longer references a separate durable retrieval layer.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + + const pages = await listWikiPages(context) + assert.ok(!pages.some((page) => page.id === 'entities-seekdb-runtime')) +}) + +test('re-ingest removes generated blocks without deleting an existing matched page', async () => { + const context = await createContext('derived-existing-page') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + await ingestWikiSource( + { + title: 'SeekDB Runtime', + pageType: 'entity', + content: 'SeekDB Runtime already has manual notes that should survive source re-ingest cleanup.', + sourcePath: '/notes/seekdb-runtime.md', + }, + context, + ) + + let callCount = 0 + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => { + callCount += 1 + const content = callCount === 1 + ? JSON.stringify({ + items: [ + { + name: 'SeekDB Runtime', + kind: 'entity', + summary: 'SeekDB Runtime is the durable retrieval layer described by this source.', + confidence: 0.94, + }, + ], + }) + : JSON.stringify({ items: [] }) + return new Response(JSON.stringify({ + choices: [{ message: { content } }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem relies on SeekDB Runtime for durable retrieval.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem no longer references a separate durable retrieval layer.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + + const preserved = await getWikiPage('entities-seekdb-runtime', context) + assert.match(preserved.content, /manual notes that should survive/) + assert.doesNotMatch(preserved.content, /PowerMem Source/) +}) + +test('derived ingest does not reuse source pages with matching entity titles', async () => { + const context = await createContext('derived-title-collision') + await ingestWikiSource( + { + title: 'SeekDB Runtime', + content: 'SeekDB Runtime source notes should stay a source page.', + sourcePath: '/notes/seekdb-runtime-source.md', + }, + context, + ) + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + items: [ + { + name: 'SeekDB Runtime', + kind: 'entity', + summary: 'SeekDB Runtime is the durable retrieval layer described by this source.', + confidence: 0.94, + }, + ], + }), + }, + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch + + await ingestWikiSource( + { + title: 'PowerMem Source', + content: 'PowerMem relies on SeekDB Runtime for durable retrieval.', + sourcePath: '/notes/powermem-source.md', + }, + context, + ) + + const source = await getWikiPage('sources-seekdb-runtime', context) + const entity = await getWikiPage('entities-seekdb-runtime', context) + assert.equal(source.type, 'source') + assert.equal(entity.type, 'entity') + assert.doesNotMatch(source.content, /Source Contributions/) + assert.match(entity.content, /PowerMem Source/) +}) + +test('assist classifies ordinary questions before using wiki context', async () => { + const context = await createContext('assist') + await ingestWikiSource( + { + title: 'Agent Decisions', + content: 'The team decided that AI agent answers should cite durable wiki pages when using prior research.', + sourcePath: '/notes/agent-decisions.md', + }, + context, + ) + + assert.deepEqual(classifyWikiQuestion('what is 2 + 2?'), { useWiki: false, reason: 'not_relevant' }) + assert.equal(classifyWikiQuestion('what do we know about AI agent decisions?').useWiki, true) + + const ignored = await assistWithWiki('what is 2 + 2?', { limit: 5 }, context) + assert.equal(ignored.usedWiki, false) + assert.equal(ignored.reason, 'not_relevant') + assert.equal(ignored.results.length, 0) + + const assisted = await assistWithWiki('what do we know about AI agent decisions?', { limit: 5 }, context) + assert.equal(assisted.usedWiki, true) + assert.equal(assisted.reason, 'explicit_wiki') + assert.ok(assisted.results.some((hit) => hit.title === 'Agent Decisions')) +}) + +test('link choice planning requires explicit action before URL ingestion', () => { + const choice = planWikiLinkChoice('Please use https://blog.langchain.com/building-langgraph/ for this.') + assert.equal(choice.requiresChoice, true) + assert.deepEqual(choice.urls, ['https://blog.langchain.com/building-langgraph/']) + assert.deepEqual(choice.actions.map((action) => action.id), [ + 'ingest', + 'summarize_once', + 'current_conversation_only', + ]) + assert.equal(choice.defaultAction, 'current_conversation_only') +}) + +test('synthesize creates a generated citation-backed wiki page from matching sources', async () => { + const context = await createContext('synthesis') + await ingestWikiSource( + { + title: 'Agent Patterns', + content: 'AI agents use tools, memory, and feedback loops to complete open-ended tasks. Workflows are better when paths are predefined.', + sourceUrl: 'https://example.com/agent-patterns', + confirmUrlIngest: true, + }, + context, + ) + await ingestWikiSource( + { + title: 'Agent Runtime', + content: 'Reliable AI agents need clear tool contracts, environmental feedback, and checkpoints for human review.', + sourceUrl: 'https://example.com/agent-runtime', + confirmUrlIngest: true, + }, + context, + ) + + const synthesized = await synthesizeWiki( + { query: 'what do we know about AI agents?', limit: 5 }, + context, + ) + + assert.equal(synthesized.title, 'AI Agents') + assert.equal(synthesized.pagesCreated, 1) + assert.equal(synthesized.sourcePageIds.length, 2) + assert.equal(synthesized.citations.length, 2) + assert.ok(synthesized.memoryId) + + const page = await getWikiPage(synthesized.page.id, context) + assert.equal(page.type, 'synthesis') + assert.match(page.content, /Generated Synthesis/) + assert.match(page.content, /\[\[Agent Patterns\]\]/) + assert.match(page.content, /\[\[Agent Runtime\]\]/) + assert.deepEqual(page.backlinks, []) + assert.deepEqual( + page.citations + .map((citation) => [citation.title, citation.sourceUrl]) + .sort((left, right) => String(left[0]).localeCompare(String(right[0]))), + [ + ['Agent Patterns', 'https://example.com/agent-patterns'], + ['Agent Runtime', 'https://example.com/agent-runtime'], + ], + ) + + const source = await getWikiPage('sources-agent-patterns', context) + assert.ok(source.backlinks.includes(synthesized.page.id)) + + const regenerated = await synthesizeWiki( + { query: 'what do we know about AI agents?', limit: 5 }, + context, + ) + assert.equal(regenerated.pagesUpdated, 1) + assert.ok(!regenerated.sourcePageIds.includes(synthesized.page.id)) + const regeneratedPage = await getWikiPage(regenerated.page.id, context) + assert.doesNotMatch(regeneratedPage.content, /\[\[AI Agents\]\]/) +}) + +test('synthesis and snippets strip generated wiki blocks before rendering durable output', async () => { + const context = await createContext('synthesis-sanitized') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + items: [ + { + name: 'SeekDB Runtime', + kind: 'entity', + summary: 'SeekDB Runtime provides the durable retrieval layer for PowerMem.', + confidence: 0.96, + }, + ], + }), + }, + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch + + await ingestWikiSource( + { + title: 'PowerMem Durable Retrieval', + content: 'PowerMem durable retrieval relies on SeekDB Runtime for persisted context and recall.', + sourcePath: '/notes/powermem-durable-retrieval.md', + }, + context, + ) + + const results = await searchWiki('powermem durable retrieval', { limit: 5 }, context) + const sourceResult = results.find((result) => result.title === 'PowerMem Durable Retrieval') + const derivedResult = results.find((result) => result.title === 'SeekDB Runtime') + assert.ok(sourceResult) + assert.ok(derivedResult) + assert.doesNotMatch(sourceResult.snippet, /Extracted Wiki Links/) + assert.doesNotMatch(sourceResult.snippet, /\[\[SeekDB Runtime\]\]/) + assert.match(derivedResult.snippet, /durable retrieval layer/) + + const synthesized = await synthesizeWiki( + { query: 'powermem durable retrieval', limit: 5 }, + context, + ) + assert.equal(synthesized.title, 'Synthesis Powermem Durable Retrieval') + const page = await getWikiPage(synthesized.page.id, context) + assert.doesNotMatch(page.content, /CLAWMASTER-GENERATED/) + assert.doesNotMatch(page.content, /## Extracted Wiki Links/) + assert.ok(!page.links.includes('durable-re-seekdb-runtime')) + + const lint = await lintWiki(context) + assert.deepEqual(lint.issues, []) +}) + +test('url ingest requires explicit confirmation', async () => { + const context = await createContext('url') + const pending = await ingestWikiSource( + { + title: 'Example Source', + sourceUrl: 'https://example.com/source', + }, + context, + ) + + assert.equal(pending.state, 'needs_confirmation') + assert.equal(pending.confirmationRequired, true) + + const confirmed = await ingestWikiSource( + { + title: 'Example Source', + sourceUrl: 'https://example.com/source', + content: 'Fetched example source body.', + confirmUrlIngest: true, + }, + context, + ) + assert.equal(confirmed.state, 'ingested') + + const contentOnlyUrl = await ingestWikiSource( + { + content: 'https://example.com/other-source', + }, + context, + ) + assert.equal(contentOnlyUrl.state, 'needs_confirmation') +}) + +test('write contexts automatically evolve wiki metadata after ingest and synthesis', async () => { + const context = await createContext('auto-evolve') + const writeContext = { ...context, autoEvolveOnWrite: true } + const created = await ingestWikiSource( + { + title: 'Auto Evolution Source', + content: 'Auto evolution keeps wiki health and related-page metadata current for agent evaluation notes.', + sourcePath: '/notes/auto-evolution.md', + }, + writeContext, + ) + + assert.equal(created.state, 'ingested') + assert.ok(created.evolve) + assert.ok(created.evolve.changedPageIds.includes(created.page!.id)) + const createdPage = await getWikiPage(created.page!.id, context) + assert.equal(createdPage.lifecycleState, 'evolved') + assert.equal(createdPage.evolveCheckedAt, created.evolve.evolvedAt) + assert.match(createdPage.frontmatter.evolveChangeSummary, /Evolution evidence initialized/) + + const synthesized = await synthesizeWiki( + { query: 'what do we know about auto evolution?', limit: 3 }, + writeContext, + ) + + assert.ok(synthesized.evolve) + assert.ok(synthesized.evolve.changedPageIds.includes(synthesized.page.id)) + const synthesisPage = await getWikiPage(synthesized.page.id, context) + assert.equal(synthesisPage.evolveCheckedAt, synthesized.evolve.evolvedAt) +}) + +test('lint flags orphan and missing linked pages, evolve records freshness', async () => { + const context = await createContext('lint') + const created = await ingestWikiSource( + { + title: 'Isolated Page', + content: 'This article references [[Missing Concept]] and has no backlinks.', + sourcePath: '/notes/isolated.md', + }, + context, + ) + assert.ok(created.page) + const current = await ingestWikiSource( + { + title: 'Standalone Page', + content: 'This article has no links.', + sourcePath: '/notes/standalone.md', + }, + context, + ) + + const lint = await lintWiki(context) + assert.ok(lint.issues.some((issue) => issue.kind === 'missing-link')) + assert.ok(lint.issues.some((issue) => issue.kind === 'orphan')) + const paths = resolveWikiPaths(context) + const lintConflicts = JSON.parse(await fs.readFile(paths.conflictsPath, 'utf8')) as Array<{ kind: string }> + assert.ok(lintConflicts.some((issue) => issue.kind === 'missing-link')) + assert.ok(!lintConflicts.some((issue) => issue.kind === 'orphan')) + assert.equal((await getWikiStatus(context)).conflictCount, 1) + + const detailBeforeEvolve = await getWikiPage(created.page!.id, context) + const rawBeforeEvolve = await fs.readFile(detailBeforeEvolve.path, 'utf8') + await fs.writeFile( + detailBeforeEvolve.path, + rawBeforeEvolve + .replace(/updatedAt: .+/, 'updatedAt: "2000-01-01T00:00:00.000Z"') + .replace(/memoryId: .+/, 'memoryId: 704468483663986688'), + 'utf8', + ) + + const evolved = await evolveWiki(context) + assert.equal(evolved.mode, 'mechanical') + assert.equal(evolved.pageCount, 2) + assert.equal(evolved.staleCount, 1) + assert.equal(evolved.conflictCount, 2) + assert.deepEqual(evolved.related[created.page!.id], []) + assert.ok(evolved.changedPageIds.includes(created.page!.id)) + assert.ok(evolved.freshness[created.page!.id]) + assert.equal(evolved.freshness[current.page!.id].status, 'fresh') + + const evolvedPage = await getWikiPage(created.page!.id, context) + assert.equal(evolvedPage.freshnessStatus, 'stale') + assert.equal(evolvedPage.frontmatter.evolveChangedAt, evolved.evolvedAt) + assert.match(evolvedPage.frontmatter.evolveChangeSummary, /Freshness changed from fresh to stale/) + assert.equal(evolvedPage.evolveCheckedAt, evolved.evolvedAt) + assert.equal(evolvedPage.frontmatter.memoryId, '704468483663986688') + + const conflicts = JSON.parse(await fs.readFile(paths.conflictsPath, 'utf8')) as Array<{ kind: string }> + assert.ok(conflicts.some((issue) => issue.kind === 'missing-link')) + assert.ok(conflicts.some((issue) => issue.kind === 'stale')) + const related = JSON.parse(await fs.readFile(path.join(paths.metaRoot, 'related.json'), 'utf8')) as Record + assert.deepEqual(related[created.page!.id], []) +}) + +test('contradiction detection sanitizes and truncates page content before sending to llm', async () => { + const context = await createContext('contradiction-truncate') + // Ingest without a model configured so LLM extraction is skipped during + // ingest itself; the large pages are still written to disk. LLM is only + // enabled afterwards for the lint contradiction check. + const longContent = 'X'.repeat(10_000) + await ingestWikiSource( + { title: 'Big Page A', content: `[[Big Page B]] ${longContent}`, sourcePath: '/a.md' }, + context, + ) + await ingestWikiSource( + { title: 'Big Page B', content: longContent, sourcePath: '/b.md' }, + context, + ) + + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + setWikiLlmTransportForTests('gateway-fetch') + let receivedBodyLength = 0 + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { messages?: Array<{ content?: string }> } + const userMessage = body.messages?.find((m) => m.content?.includes('Page 1'))?.content ?? '' + receivedBodyLength = userMessage.length + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ contradictions: [] }) } }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) as typeof fetch + + await lintWiki(context) + assert.ok( + receivedBodyLength < 7_500, + `contradiction check sent ${receivedBodyLength} chars — expected < 7500 after truncation`, + ) +}) + +test('lint emits contradiction issues when llm contradiction checks find a conflict', async () => { + const context = await createContext('contradiction') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + await ingestWikiSource( + { + title: 'Runtime Claim A', + content: '[[Runtime Claim B]] says the runtime is local-only.', + sourcePath: '/notes/runtime-a.md', + }, + context, + ) + await ingestWikiSource( + { + title: 'Runtime Claim B', + content: 'Runtime Claim B says the runtime is remote-first.', + sourcePath: '/notes/runtime-b.md', + }, + context, + ) + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + contradictions: [ + { + claim1: 'local-only', + claim2: 'remote-first', + explanation: 'One page says the runtime is local-only while the other says it is remote-first.', + }, + ], + }), + }, + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch + + const lint = await lintWiki(context) + assert.ok(lint.issues.some((issue) => issue.kind === 'contradiction')) + const conflicts = JSON.parse(await fs.readFile(resolveWikiPaths(context).conflictsPath, 'utf8')) as Array<{ kind: string }> + assert.ok(conflicts.some((issue) => issue.kind === 'contradiction')) +}) + +test('deep evolve revises stale pages through the wiki llm without changing the mechanical endpoint', async () => { + const context = await createContext('deep-evolve') + await writeWikiConfig(context, { + agents: { defaults: { model: { primary: 'openai/gpt-4o-mini' } } }, + }) + const created = await ingestWikiSource( + { + title: 'Stale Runtime Notes', + content: 'The runtime status is pending further verification.', + sourcePath: '/notes/stale-runtime.md', + }, + context, + ) + const detail = await getWikiPage(created.page!.id, context) + const raw = await fs.readFile(detail.path, 'utf8') + await fs.writeFile( + detail.path, + raw.replace(/updatedAt: .+/, 'updatedAt: "2000-01-01T00:00:00.000Z"'), + 'utf8', + ) + + setWikiLlmUseGatewayFetchForTests(true) + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: 'The runtime status is now verified and actively maintained.', + }, + }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch + + const evolved = await evolveWikiDeep(context) + assert.equal(evolved.mode, 'deep') + assert.equal(evolved.staleCount, 0) + assert.ok(evolved.changedPageIds.includes(created.page!.id)) + const revised = await getWikiPage(created.page!.id, context) + assert.equal(revised.frontmatter.evolveSource, 'llm-deep-evolve') + assert.equal(revised.freshnessStatus, 'fresh') + assert.match(revised.content, /verified and actively maintained/) +}) + +test('updated pages with evolve evidence keep the evolved lifecycle state', async () => { + const context = await createContext('lifecycle') + const created = await ingestWikiSource( + { + title: 'Updated Agent Page', + content: 'This updated page has enough content to be useful later.', + sourcePath: '/notes/updated-agent.md', + }, + context, + ) + assert.ok(created.page) + + const detail = await getWikiPage(created.page!.id, context) + const raw = await fs.readFile(detail.path, 'utf8') + await fs.writeFile( + detail.path, + raw + .replace(/createdAt: .+/, 'createdAt: "2026-01-01T00:00:00.000Z"') + .replace(/updatedAt: .+/, 'updatedAt: "2026-01-02T00:00:00.000Z"') + .replace(/freshnessScore: .+/, 'freshnessScore: 1') + .replace(/freshnessStatus: .+/, 'freshnessStatus: "fresh"') + .replace(/\n---\n/, '\nevolveChangedAt: "2026-01-03T00:00:00.000Z"\nevolveChangeSummary: "Related pages updated."\n---\n'), + 'utf8', + ) + + const evolved = await getWikiPage(created.page!.id, context) + assert.equal(evolved.lifecycleState, 'evolved') +}) diff --git a/packages/backend/src/services/wikiService.ts b/packages/backend/src/services/wikiService.ts new file mode 100644 index 00000000..fb83c725 --- /dev/null +++ b/packages/backend/src/services/wikiService.ts @@ -0,0 +1,2589 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { + addManagedMemory, + deleteManagedMemory, + listManagedMemories, + resolveManagedMemoryStoreContext, + searchManagedMemories, + type ManagedMemoryContext, +} from './managedMemory.js' +import { + getOpenclawProfileSelection, + type OpenclawProfileContext, + type OpenclawProfileSelection, +} from '../openclawProfile.js' +import { + ensureWikiVaultStructure, + resolveWikiVaultLayout, + type WikiVaultLayout, +} from '../openclawVaultPaths.js' +import { + wikiLlmComplete, + wikiLlmCompleteStructured, + wikiLlmEnabled, + type WikiLlmMessage, +} from './wikiLlm.js' + +export type WikiPageType = 'entity' | 'concept' | 'source' | 'synthesis' | 'process' +export type WikiFreshnessStatus = 'fresh' | 'aging' | 'stale' +export type WikiLifecycleState = 'just_ingested' | 'updated' | 'evolved' | 'outdated' +export type WikiIngestState = 'ingested' | 'updated' | 'skipped' | 'needs_confirmation' +export type WikiLintSeverity = 'info' | 'warning' | 'error' + +export interface WikiServiceContext extends OpenclawProfileContext { + profileSelection?: OpenclawProfileSelection + vaultRootOverride?: string + managedMemoryContext?: ManagedMemoryContext + autoEvolveOnWrite?: boolean +} + +export interface WikiStatusPayload { + profileKey: string + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string + pageCount: number + sourceCount: number + staleCount: number + conflictCount: number + memory: { + engine: string + storagePath: string + } +} + +export interface WikiPageSummary { + id: string + title: string + type: WikiPageType + path: string + relativePath: string + snippet: string + sourceCount: number + freshnessStatus: WikiFreshnessStatus + freshnessScore: number + lifecycleState: WikiLifecycleState + createdAt: string + updatedAt: string + evolvedAt: string + evolveCheckedAt: string + evolveChangedAt: string + evolveChangeSummary: string + evolveSource: string + lastAccessedAt: string + links: string[] + backlinks: string[] + memoryIds: string[] +} + +export interface WikiPageDetail extends WikiPageSummary { + content: string + frontmatter: Record + citations: WikiCitation[] +} + +export interface WikiCitation { + title: string + sourcePath?: string + sourceUrl?: string +} + +export interface WikiSearchResult extends WikiPageSummary { + score: number + matchType: 'keyword' | 'semantic' +} + +export interface WikiIngestInput { + title?: string + content?: string + sourceUrl?: string + sourcePath?: string + sourceType?: string + pageType?: WikiPageType + confirmUrlIngest?: boolean +} + +export interface WikiIngestPayload { + state: WikiIngestState + confirmationRequired: boolean + message: string + page?: WikiPageSummary + memoryId?: string + pagesCreated: number + pagesUpdated: number + warnings: string[] + evolve?: WikiEvolvePayload +} + +export interface WikiQueryPayload { + query: string + usedWiki: boolean + answer: string + results: WikiSearchResult[] + citations: WikiCitation[] + offerToSave: boolean + warnings?: string[] +} + +export type WikiAssistReason = 'explicit_wiki' | 'knowledge_question' | 'project_context' | 'not_relevant' + +export interface WikiAssistPayload extends WikiQueryPayload { + reason: WikiAssistReason +} + +export interface WikiSynthesizeInput { + query: string + title?: string + limit?: number +} + +export interface WikiSynthesizePayload { + title: string + query: string + page: WikiPageSummary + memoryId: string + pagesCreated: number + pagesUpdated: number + sourcePageIds: string[] + citations: WikiCitation[] + warnings: string[] + evolve?: WikiEvolvePayload +} + +export interface WikiLintIssue { + id: string + severity: WikiLintSeverity + kind: 'orphan' | 'missing-link' | 'duplicate-title' | 'stale' | 'schema' | 'contradiction' + pageId?: string + title: string + detail: string +} + +export interface WikiLintPayload { + checkedAt: string + issueCount: number + issues: WikiLintIssue[] + warnings?: string[] +} + +export interface WikiEvolvePayload { + mode: 'mechanical' | 'deep' + evolvedAt: string + pageCount: number + staleCount: number + conflictCount: number + changedPageIds: string[] + related: Record + warnings: string[] + freshness: Record +} + +export type WikiLinkAction = 'ingest' | 'summarize_once' | 'current_conversation_only' + +export interface WikiLinkChoicePayload { + input: string + urls: string[] + requiresChoice: boolean + defaultAction: WikiLinkAction + actions: Array<{ + id: WikiLinkAction + label: string + description: string + }> + message: string +} + +interface WikiPaths { + profileKey: string + vaultRoot: string + rawRoot: string + pagesRoot: string + metaRoot: string + indexPath: string + logPath: string + schemaPath: string + freshnessPath: string + conflictsPath: string +} + +interface WikiIngestStateEntry { + fingerprint: string + pageId?: string + memoryId?: string + primaryPageId: string + primaryMemoryId?: string + derivedPageIds: string[] + memoryIds: string[] + derivedPages: Array<{ + pageId: string + memoryId?: string + pageType: WikiPageType + sourceFingerprint: string + }> + updatedAt: string +} + +interface WikiIngestStateFile { + version: 1 + sources: Record +} + +interface ParsedPage { + filePath: string + relativePath: string + frontmatter: Record + body: string +} + +interface WikiDerivedSuggestion { + name: string + kind: 'entity' | 'concept' + summary: string + confidence: number + existingTitle?: string +} + +interface WikiDerivedResult { + pageId: string + pageType: WikiPageType + memoryId?: string + sourceFingerprint: string + created: boolean +} + +type WikiFreshnessMeta = Record + +const PAGE_DIRS: Record = { + entity: 'entities', + concept: 'concepts', + source: 'sources', + synthesis: 'synthesis', + process: 'processes', +} + +const DEFAULT_FRESHNESS_SCORE = 1 +const GENERATED_BLOCK_PREFIX = 'CLAWMASTER-GENERATED' +const DERIVED_PAGE_CONFIDENCE_THRESHOLD = 0.72 +const MAX_INGEST_LLM_CHARS = 12_000 +const MAX_DEEP_EVOLVE_PAGES = 5 +const MAX_CONTRADICTION_PAGE_CHARS = 3_000 +const MAX_CONTRADICTION_PAIRS = 10 + +function nowIso(): string { + return new Date().toISOString() +} + +function logWikiLlmFailure(code: string, error: unknown, extra?: Record): void { + const message = error instanceof Error ? error.message : String(error) + // Surface LLM failures as a warning log so operators can triage gateway/auth + // misconfiguration in the field. Callers still propagate a stable warning + // code through the response payload; this adds visibility without changing + // the contract. + console.warn(`[wiki] ${code}: ${message}`, extra ?? {}) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function blockIdForSource(sourcePageId: string): string { + return `${GENERATED_BLOCK_PREFIX}:${sourcePageId}` +} + +function generatedBlockMarkers(blockId: string): { start: string; end: string } { + return { + start: ``, + end: ``, + } +} + +function upsertGeneratedBlock(body: string, blockId: string, blockContent: string): string { + const normalized = body.trim() + const { start, end } = generatedBlockMarkers(blockId) + const pattern = new RegExp(`\\n?${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, 'g') + const replacement = blockContent.trim() + ? `\n\n${start}\n${blockContent.trim()}\n${end}\n` + : '\n' + const updated = normalized.match(pattern) + ? normalized.replace(pattern, replacement) + : replacement.trim() + ? `${normalized}${replacement}` + : normalized + return updated + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function removeGeneratedBlock(body: string, blockId: string): string { + return upsertGeneratedBlock(body, blockId, '') +} + + +function buildDerivedContributionBlock(sourceTitle: string, summary: string): string { + return [ + `### From [[${sourceTitle}]]`, + '', + summary.trim(), + ].join('\n') +} + +function generatedDerivedPageIntro(pageType: WikiPageType): string { + return `This ${pageType} page aggregates generated wiki notes tied to imported sources.` +} + +function ensureContributionSection(body: string): string { + return body.includes('## Source Contributions') + ? body + : `${body.trim()}\n\n## Source Contributions` +} + +function stripHeading(title: string, body: string): string { + const trimmed = body.trimStart() + const heading = `# ${title}`.trim() + return trimmed.startsWith(heading) ? trimmed.slice(heading.length).trimStart() : body.trim() +} + +function stripMarkdownSection(body: string, sectionTitle: string): string { + const escaped = escapeRegExp(sectionTitle) + return body.replace(new RegExp(`(?:^|\\n)## ${escaped}\\s*\\n[\\s\\S]*?(?=\\n##\\s|\\n#\\s|$)`, 'g'), '\n') +} + +function sanitizeWikiBody(title: string, body: string): string { + const withoutHeading = stripHeading(title, body) + // Strip any HTML comment — the CLAWMASTER-GENERATED markers and any other + // comment block are all covered by the generic pattern below. + const withoutComments = withoutHeading.replace(//g, '\n') + return ['Extracted Wiki Links', 'Sources'] + .reduce((content, sectionTitle) => stripMarkdownSection(content, sectionTitle), withoutComments) + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function pruneEmptyContributionSection(body: string): string { + return body + .replace(/\n## Source Contributions(?:\s*\n*)?$/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function sanitizeContentForSynthesis(page: WikiPageDetail): string { + return sanitizeWikiBody(page.title, page.content) +} + +function getProfileKey(profileSelection: OpenclawProfileSelection): string { + if (profileSelection.kind === 'named' && profileSelection.name) return `named:${profileSelection.name}` + return profileSelection.kind +} + +export function resolveWikiPaths(context: WikiServiceContext = {}): WikiPaths { + const profileSelection = context.profileSelection ?? getOpenclawProfileSelection(context) + const layout: WikiVaultLayout = resolveWikiVaultLayout({ + homeDir: context.homeDir, + platform: context.platform, + profileSelection, + vaultRootOverride: context.vaultRootOverride, + dataRootOverride: context.managedMemoryContext?.dataRootOverride, + }) + return { + profileKey: getProfileKey(profileSelection), + vaultRoot: layout.vaultRoot, + rawRoot: layout.rawRoot, + pagesRoot: layout.pagesRoot, + metaRoot: layout.metaRoot, + indexPath: layout.indexPath, + logPath: layout.logPath, + schemaPath: layout.schemaPath, + freshnessPath: layout.freshnessPath, + conflictsPath: layout.conflictsPath, + } +} + +function statePath(paths: WikiPaths): string { + return path.join(paths.metaRoot, 'ingest-state.json') +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.stat(targetPath) + return true + } catch { + return false + } +} + +async function writeIfMissing(filePath: string, content: string): Promise { + if (await pathExists(filePath)) return + await fs.writeFile(filePath, content, 'utf8') +} + +export async function ensureWikiVault(context: WikiServiceContext = {}): Promise { + const paths = resolveWikiPaths(context) + await ensureWikiVaultStructure({ + vaultRoot: paths.vaultRoot, + rawRoot: paths.rawRoot, + pagesRoot: paths.pagesRoot, + metaRoot: paths.metaRoot, + indexPath: paths.indexPath, + logPath: paths.logPath, + schemaPath: paths.schemaPath, + freshnessPath: paths.freshnessPath, + conflictsPath: paths.conflictsPath, + ingestStatePath: statePath(paths), + }) + return paths +} + +function slugify(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/['"]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return normalized || `wiki-${Date.now()}` +} + +function fingerprintSource(input: { + title: string + content: string + sourceUrl?: string + sourcePath?: string +}): string { + return createHash('sha256') + .update(input.title) + .update('\n') + .update(input.sourceUrl ?? '') + .update('\n') + .update(input.sourcePath ?? '') + .update('\n') + .update(input.content) + .digest('hex') +} + +function sourceKey(input: WikiIngestInput, title: string): string { + return input.sourceUrl?.trim() || input.sourcePath?.trim() || `title:${slugify(title)}` +} + +function looksLikeUrl(value: string | undefined): boolean { + return Boolean(value && /^https?:\/\//i.test(value.trim())) +} + +function extractHttpUrls(value: string): string[] { + const urls = new Set() + const pattern = /https?:\/\/[^\s<>"')\]]+/gi + let match: RegExpExecArray | null + while ((match = pattern.exec(value))) { + const normalized = match[0].replace(/[.,;:!?]+$/, '') + if (normalized) urls.add(normalized) + } + return [...urls] +} + +export function classifyWikiQuestion(query: string): { useWiki: boolean; reason: WikiAssistReason } { + const text = query.trim().toLowerCase() + if (text.length < 5) return { useWiki: false, reason: 'not_relevant' } + + if (/\b(wiki|knowledge base|kb|what do we know|what have we learned|known about)\b/i.test(text)) { + return { useWiki: true, reason: 'explicit_wiki' } + } + + if (/\b(prior|previous|earlier|saved|remembered|notes?|docs?|documentation|research|sources?|articles?|citations?|decision|decisions|rationale)\b/i.test(text)) { + return { useWiki: true, reason: 'knowledge_question' } + } + + if (/\b(project context|codebase context|architecture|design choice|implementation plan|roadmap|issue #?\d+|pr #?\d+)\b/i.test(text)) { + return { useWiki: true, reason: 'project_context' } + } + + return { useWiki: false, reason: 'not_relevant' } +} + +function inferTitle(input: WikiIngestInput): string { + if (input.title?.trim()) return input.title.trim() + if (input.sourceUrl?.trim()) { + try { + const url = new URL(input.sourceUrl.trim()) + return url.hostname + url.pathname.replace(/\/$/, '') + } catch { + return input.sourceUrl.trim() + } + } + if (input.sourcePath?.trim()) return path.basename(input.sourcePath.trim()) + const firstLine = input.content?.split(/\r?\n/).find((line) => line.trim())?.trim() + return firstLine?.replace(/^#+\s*/, '').slice(0, 80) || 'Untitled wiki article' +} + +function inferPageType(input: WikiIngestInput): WikiPageType { + return input.pageType ?? 'source' +} + +function normalizeContent(input: WikiIngestInput): string { + const content = input.content?.trim() + if (content) return content + if (input.sourceUrl?.trim()) return `Source URL: ${input.sourceUrl.trim()}` + return '' +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") +} + +function htmlToReadableText(html: string): { title?: string; text: string } { + const title = decodeHtmlEntities(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? '') + const text = decodeHtmlEntities( + html + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<\/(p|div|section|article|header|footer|li|h[1-6]|tr)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n\s+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(), + ) + return { title: title || undefined, text } +} + +async function fetchUrlContent(sourceUrl: string): Promise<{ title?: string; content: string }> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch(sourceUrl, { + signal: controller.signal, + headers: { + accept: 'text/html,text/plain,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'user-agent': 'OpenClaw-Wiki/1.0', + }, + }) + if (!response.ok) { + throw new Error(`URL fetch failed with HTTP ${response.status}`) + } + const raw = (await response.text()).slice(0, 500_000) + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('html') || /]/i.test(raw)) { + const parsed = htmlToReadableText(raw) + return { + title: parsed.title, + content: parsed.text || `Source URL: ${sourceUrl}`, + } + } + return { content: raw.trim() || `Source URL: ${sourceUrl}` } + } finally { + clearTimeout(timeout) + } +} + +type FrontmatterScalar = string | number | boolean | null | undefined +type FrontmatterValue = FrontmatterScalar | string[] + +function isFilledArray(value: FrontmatterValue): value is string[] { + return Array.isArray(value) +} + +function renderFrontmatter(values: Record): string { + const lines = Object.entries(values) + .filter(([, value]) => { + if (value === undefined || value === null) return false + if (typeof value === 'string' && value === '') return false + if (Array.isArray(value) && value.length === 0) return false + return true + }) + .map(([key, value]) => { + if (typeof value === 'number' || typeof value === 'boolean') return `${key}: ${value}` + if (isFilledArray(value)) { + const items = value.map((item) => JSON.stringify(String(item))).join(', ') + return `${key}: [${items}]` + } + return `${key}: ${JSON.stringify(String(value))}` + }) + return `---\n${lines.join('\n')}\n---\n` +} + +function parseFrontmatterScalar(rawValue: string): string { + if (rawValue.startsWith('[') && rawValue.endsWith(']')) { + try { + const parsed = JSON.parse(rawValue) as unknown + if (Array.isArray(parsed)) { + return parsed + .map((item) => String(item).trim()) + .filter(Boolean) + .join('|') + } + } catch { + /* fall through to plain string parsing */ + } + } + if (/^-?\d+$/.test(rawValue) && !Number.isSafeInteger(Number(rawValue))) return rawValue + try { + const parsed = JSON.parse(rawValue) as unknown + if (Array.isArray(parsed)) { + return parsed + .map((item) => String(item).trim()) + .filter(Boolean) + .join('|') + } + return String(parsed) + } catch { + return rawValue + } +} + +function parseFrontmatter(raw: string): { frontmatter: Record; body: string } { + if (!raw.startsWith('---')) return { frontmatter: {}, body: raw } + const end = raw.indexOf('\n---', 3) + if (end < 0) return { frontmatter: {}, body: raw } + const block = raw.slice(3, end).trim() + const body = raw.slice(end + 4).replace(/^\r?\n/, '') + const frontmatter: Record = {} + for (const line of block.split(/\r?\n/)) { + const index = line.indexOf(':') + if (index < 0) continue + const key = line.slice(0, index).trim() + const rawValue = line.slice(index + 1).trim() + if (!key) continue + frontmatter[key] = parseFrontmatterScalar(rawValue) + } + return { frontmatter, body } +} + +const LIST_FRONTMATTER_KEYS = new Set(['generatedFromSourceIds', 'relatedPages', 'relatedPageIds']) + +function normalizeFrontmatterValueForWrite(key: string, value: FrontmatterValue): FrontmatterValue { + if (Array.isArray(value)) return value + if (LIST_FRONTMATTER_KEYS.has(key) && typeof value === 'string' && value.length > 0) { + return value.split('|').map((item) => item.trim()).filter(Boolean) + } + return value +} + +function renderMarkdownWithFrontmatter( + frontmatter: Record, + body: string, +): string { + const normalized: Record = {} + for (const [key, value] of Object.entries(frontmatter)) { + normalized[key] = normalizeFrontmatterValueForWrite(key, value) + } + return `${renderFrontmatter(normalized)}\n${body.replace(/^\r?\n/, '')}` +} + +function readListFrontmatter(value: string | undefined): string[] { + if (!value) return [] + return value + .split('|') + .map((item) => item.trim()) + .filter(Boolean) +} + +function serializeFrontmatterList(values: string[]): string { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].join('|') +} + +function extractWikiLinks(content: string): string[] { + const links = new Set() + const pattern = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g + let match: RegExpExecArray | null + while ((match = pattern.exec(content))) { + const link = match[1]?.trim() + if (link) links.add(link) + } + return [...links] +} + +function makeSnippet(content: string, query = ''): string { + const compact = content.replace(/\s+/g, ' ').trim() + if (!query.trim()) return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact + const lower = compact.toLowerCase() + const needle = query.trim().toLowerCase() + const index = lower.indexOf(needle) + if (index < 0) return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact + const start = Math.max(0, index - 70) + const end = Math.min(compact.length, index + needle.length + 90) + return `${start > 0 ? '...' : ''}${compact.slice(start, end)}${end < compact.length ? '...' : ''}` +} + +function freshnessStatus(score: number): WikiFreshnessStatus { + if (score < 0.35) return 'stale' + if (score < 0.7) return 'aging' + return 'fresh' +} + +function lifecycleState(frontmatter: Record, freshness: WikiFreshnessStatus): WikiLifecycleState { + if (freshness === 'stale') return 'outdated' + + if (frontmatter.evolveChangedAt) return 'evolved' + + const createdMs = Date.parse(frontmatter.createdAt || '') + const nowMs = Date.now() + if (Number.isFinite(createdMs) && nowMs - createdMs < 86_400_000) { + return 'just_ingested' + } + + const createdAt = frontmatter.createdAt || '' + const updatedAt = frontmatter.updatedAt || '' + if (createdAt && updatedAt && createdAt !== updatedAt) { + return 'updated' + } + + return 'updated' +} + +function parseNumber(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function countSources(frontmatter: Record, body: string): number { + const explicitCount = frontmatter.sourceCount ? parseNumber(frontmatter.sourceCount, 0) : 0 + if (explicitCount > 0) return explicitCount + + const sources = new Set() + if (frontmatter.sourceUrl) sources.add(frontmatter.sourceUrl) + if (frontmatter.sourcePath) sources.add(frontmatter.sourcePath) + const sourceSection = body.match(/^## Sources\s*\n+([\s\S]*?)(?:\n##\s|\n#\s|$)/m)?.[1] ?? '' + for (const line of sourceSection.matchAll(/^(?:[-*]|\d+\.)\s+(.+)$/gm)) { + const source = line[1]?.trim() + if (source) sources.add(source) + } + return sources.size +} + +function parseCitations(frontmatter: Record): WikiCitation[] { + if (frontmatter.sourceUrls || frontmatter.sourcePaths) { + const urls = (frontmatter.sourceUrls ?? '').split('|').map((item) => item.trim()).filter(Boolean) + const paths = (frontmatter.sourcePaths ?? '').split('|').map((item) => item.trim()).filter(Boolean) + const titles = (frontmatter.sourceTitles ?? '').split('|').map((item) => item.trim()).filter(Boolean) + const count = Math.max(urls.length, paths.length, titles.length) + return Array.from({ length: count }, (_, index) => ({ + title: titles[index] || urls[index] || paths[index] || 'Source', + sourceUrl: urls[index], + sourcePath: paths[index], + })).filter((citation) => citation.sourceUrl || citation.sourcePath) + } + const citation: WikiCitation = { + title: frontmatter.sourceTitle || frontmatter.title || 'Source', + sourcePath: frontmatter.sourcePath || undefined, + sourceUrl: frontmatter.sourceUrl || undefined, + } + return citation.sourcePath || citation.sourceUrl ? [citation] : [] +} + +async function readJsonFile(filePath: string, fallback: T): Promise { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as T + } catch { + return fallback + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +async function readIngestState(paths: WikiPaths): Promise { + const parsed = await readJsonFile(statePath(paths), { version: 1, sources: {} }) + const normalizedSources = Object.fromEntries( + Object.entries(parsed.sources && typeof parsed.sources === 'object' ? parsed.sources : {}).map(([key, value]) => { + const entry: Record = isRecord(value) ? value : {} + const primaryPageId = typeof entry.primaryPageId === 'string' + ? entry.primaryPageId + : typeof entry.pageId === 'string' + ? entry.pageId + : '' + const primaryMemoryId = typeof entry.primaryMemoryId === 'string' + ? entry.primaryMemoryId + : typeof entry.memoryId === 'string' + ? entry.memoryId + : undefined + const derivedPages = Array.isArray(entry.derivedPages) + ? entry.derivedPages + .filter(isRecord) + .map((page: Record) => ({ + pageId: typeof page.pageId === 'string' ? page.pageId : '', + memoryId: typeof page.memoryId === 'string' ? page.memoryId : undefined, + pageType: (page.pageType as WikiPageType | undefined) ?? 'source', + sourceFingerprint: typeof page.sourceFingerprint === 'string' ? page.sourceFingerprint : '', + })) + .filter((page) => page.pageId) + : [] + return [ + key, + { + fingerprint: typeof entry.fingerprint === 'string' ? entry.fingerprint : '', + pageId: typeof entry.pageId === 'string' ? entry.pageId : primaryPageId, + memoryId: typeof entry.memoryId === 'string' ? entry.memoryId : primaryMemoryId, + primaryPageId, + primaryMemoryId, + derivedPageIds: Array.isArray(entry.derivedPageIds) + ? entry.derivedPageIds.filter((item: unknown): item is string => typeof item === 'string' && item.trim().length > 0) + : derivedPages.map((page) => page.pageId), + memoryIds: Array.isArray(entry.memoryIds) + ? entry.memoryIds.filter((item: unknown): item is string => typeof item === 'string' && item.trim().length > 0) + : [primaryMemoryId, ...derivedPages.map((page) => page.memoryId)].filter((item): item is string => Boolean(item)), + derivedPages, + updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : nowIso(), + } satisfies WikiIngestStateEntry, + ] + }), + ) + return { + version: 1, + sources: normalizedSources, + } +} + +async function writeIngestState(paths: WikiPaths, state: WikiIngestStateFile): Promise { + await writeJsonFile(statePath(paths), state) +} + +async function collectMarkdownFiles(root: string, out: string[]): Promise { + let entries: Array + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const filePath = path.join(root, entry.name) + if (entry.isDirectory()) { + await collectMarkdownFiles(filePath, out) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(filePath) + } + } +} + +async function readParsedPages(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const files: string[] = [] + await collectMarkdownFiles(paths.pagesRoot, files) + const pages = await Promise.all( + files.map(async (filePath) => { + const raw = await fs.readFile(filePath, 'utf8') + const parsed = parseFrontmatter(raw) + return { + filePath, + relativePath: path.relative(paths.vaultRoot, filePath), + ...parsed, + } + }), + ) + return pages +} + +function pageTypeFromRelativePath(relativePath: string): WikiPageType { + const normalized = relativePath.replace(/\\/g, '/') + for (const [type, dir] of Object.entries(PAGE_DIRS) as Array<[WikiPageType, string]>) { + if (normalized.includes(`/pages/${dir}/`) || normalized.startsWith(`pages/${dir}/`)) return type + } + return 'source' +} + +function linksForPage(page: ParsedPage): string[] { + const bodyLinks = extractWikiLinks(page.body) + const frontmatterLinks = readListFrontmatter(page.frontmatter.relatedPages) + return [...new Set([...bodyLinks, ...frontmatterLinks])] +} + +function summarizeParsedPages(pages: ParsedPage[], query = '', freshnessMeta: WikiFreshnessMeta = {}): WikiPageSummary[] { + const titleToId = new Map() + for (const page of pages) { + const id = page.frontmatter.id || slugify(page.frontmatter.title || path.basename(page.filePath, '.md')) + const title = page.frontmatter.title || id + titleToId.set(title, id) + titleToId.set(id, id) + } + + const backlinksById = new Map>() + for (const page of pages) { + const fromId = page.frontmatter.id || slugify(page.frontmatter.title || path.basename(page.filePath, '.md')) + for (const link of linksForPage(page)) { + const targetId = titleToId.get(link) ?? slugify(link) + if (!backlinksById.has(targetId)) backlinksById.set(targetId, new Set()) + backlinksById.get(targetId)!.add(fromId) + } + } + + return pages.map((page) => { + const id = page.frontmatter.id || slugify(page.frontmatter.title || path.basename(page.filePath, '.md')) + const title = page.frontmatter.title || id + const type = (page.frontmatter.type as WikiPageType | undefined) ?? pageTypeFromRelativePath(page.relativePath) + const meta = freshnessMeta[id] + const score = typeof meta?.score === 'number' + ? meta.score + : parseNumber(page.frontmatter.freshnessScore, DEFAULT_FRESHNESS_SCORE) + const resolvedFreshness = meta?.status ?? (page.frontmatter.freshnessStatus as WikiFreshnessStatus | undefined) ?? freshnessStatus(score) + const evolvedAt = page.frontmatter.evolveChangedAt || '' + return { + id, + title, + type, + path: page.filePath, + relativePath: page.relativePath, + snippet: makeSnippet(sanitizeWikiBody(title, page.body), query), + sourceCount: countSources(page.frontmatter, page.body), + freshnessStatus: resolvedFreshness, + freshnessScore: score, + lifecycleState: lifecycleState(page.frontmatter, resolvedFreshness), + createdAt: page.frontmatter.createdAt || '', + updatedAt: page.frontmatter.updatedAt || page.frontmatter.createdAt || '', + evolvedAt, + evolveCheckedAt: meta?.checkedAt || page.frontmatter.evolveCheckedAt || '', + evolveChangedAt: page.frontmatter.evolveChangedAt || '', + evolveChangeSummary: page.frontmatter.evolveChangeSummary || '', + evolveSource: page.frontmatter.evolveSource || '', + lastAccessedAt: meta?.lastAccessedAt || page.frontmatter.lastAccessedAt || '', + links: linksForPage(page), + backlinks: [...(backlinksById.get(id) ?? new Set())], + memoryIds: page.frontmatter.memoryId ? [page.frontmatter.memoryId] : [], + } + }).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.title.localeCompare(right.title)) +} + +function renderWikiPage(input: { + id: string + title: string + type: WikiPageType + sourceType: string + sourcePath?: string + sourceUrl?: string + content: string + memoryId: string + fingerprint: string + createdAt: string + updatedAt: string + relatedPages?: string[] +}): string { + const fm = renderFrontmatter({ + id: input.id, + title: input.title, + type: input.type, + sourceType: input.sourceType, + sourcePath: input.sourcePath, + sourceUrl: input.sourceUrl, + sourceTitle: input.title, + memoryId: input.memoryId, + fingerprint: input.fingerprint, + durability: 'durable', + category: input.type === 'process' ? 'procedure' : 'reference', + freshnessScore: DEFAULT_FRESHNESS_SCORE, + freshnessStatus: 'fresh', + sourceCount: input.sourcePath || input.sourceUrl ? 1 : 0, + relatedPages: input.relatedPages && input.relatedPages.length > 0 ? input.relatedPages : undefined, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }) + const sources = input.sourceUrl + ? `\n## Sources\n\n- ${input.sourceUrl}\n` + : input.sourcePath + ? `\n## Sources\n\n- ${input.sourcePath}\n` + : '' + return `${fm}\n# ${input.title}\n\n${input.content.trim()}\n${sources}` +} + +function renderGeneratedWikiPage(input: { + id: string + title: string + type: WikiPageType + content: string + memoryId: string + fingerprint: string + sourcePages: WikiPageDetail[] + createdAt: string + updatedAt: string +}): string { + const citations = input.sourcePages + .flatMap((page) => page.citations) + .filter((citation) => citation.sourceUrl || citation.sourcePath) + const fm = renderFrontmatter({ + id: input.id, + title: input.title, + type: input.type, + sourceType: 'synthesis', + sourceTitle: input.title, + sourceTitles: citations.map((citation) => citation.title).join('|'), + sourceUrls: citations.map((citation) => citation.sourceUrl ?? '').join('|'), + sourcePaths: citations.map((citation) => citation.sourcePath ?? '').join('|'), + memoryId: input.memoryId, + fingerprint: input.fingerprint, + durability: 'durable', + category: 'synthesis', + freshnessScore: DEFAULT_FRESHNESS_SCORE, + freshnessStatus: 'fresh', + sourceCount: citations.length || input.sourcePages.length, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }) + const sourceLines = input.sourcePages.map((page, index) => { + const citation = page.citations[0] + const source = citation?.sourceUrl || citation?.sourcePath || page.relativePath + return `${index + 1}. [[${page.title}]] - ${source}` + }) + return `${fm}\n# ${input.title}\n\n${input.content.trim()}\n\n## Sources\n\n${sourceLines.join('\n')}\n` +} + +async function syncPageManagedMemory( + pagePath: string, + context: WikiServiceContext, +): Promise { + const raw = await fs.readFile(pagePath, 'utf8') + const parsed = parseFrontmatter(raw) + const previousMemoryId = parsed.frontmatter.memoryId || undefined + const pageId = parsed.frontmatter.id || slugify(parsed.frontmatter.title || path.basename(pagePath, '.md')) + const pageType = (parsed.frontmatter.type as WikiPageType | undefined) ?? 'source' + const created = await addManagedMemory( + { + content: parsed.body, + metadata: { + sourceType: parsed.frontmatter.sourceType || (pageType === 'synthesis' ? 'synthesis' : 'wiki-generated'), + scope: 'wiki', + durability: 'durable', + category: pageType === 'process' ? 'procedure' : pageType === 'synthesis' ? 'synthesis' : 'reference', + provenance: { + sourceType: parsed.frontmatter.sourceType || (pageType === 'synthesis' ? 'synthesis' : 'wiki-generated'), + sourcePath: parsed.frontmatter.sourcePath, + sourceUrl: parsed.frontmatter.sourceUrl, + sourceFingerprint: parsed.frontmatter.fingerprint, + sourcePageIds: readListFrontmatter(parsed.frontmatter.generatedFromSourceIds), + importedAt: nowIso(), + createdBy: parsed.frontmatter.evolveSource?.includes('llm') ? 'wiki-llm' : 'wiki-service', + }, + quality: { + confidence: pageType === 'synthesis' ? 0.78 : 0.74, + recallPriority: pageType === 'source' ? 0.7 : 0.82, + }, + pageId, + sectionId: 'body', + freshness: { + score: parseNumber(parsed.frontmatter.freshnessScore, DEFAULT_FRESHNESS_SCORE), + status: (parsed.frontmatter.freshnessStatus as WikiFreshnessStatus | undefined) ?? 'fresh', + updatedAt: parsed.frontmatter.updatedAt || nowIso(), + }, + lint: { + status: 'unchecked', + }, + }, + }, + managedContext(context), + ) + if (previousMemoryId && previousMemoryId !== created.memoryId) { + await deleteManagedMemory(previousMemoryId, managedContext(context)).catch(() => undefined) + } + const nextFrontmatter = { + ...parsed.frontmatter, + memoryId: created.memoryId, + } + await fs.writeFile(pagePath, renderMarkdownWithFrontmatter(nextFrontmatter, parsed.body), 'utf8') + return created.memoryId +} + +function buildWikiLlmMessages(system: string, user: string): WikiLlmMessage[] { + return [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ] +} + +async function extractDerivedSuggestions( + input: { + title: string + content: string + sourcePageId: string + existingTitles: string[] + }, + context: WikiServiceContext, +): Promise { + if (!wikiLlmEnabled(context) || input.content.length > MAX_INGEST_LLM_CHARS) return [] + const schema = { + items: [ + { + name: 'string', + kind: 'entity | concept', + summary: 'string', + confidence: 'number', + existingTitle: 'string | optional', + }, + ], + } + const response = await wikiLlmCompleteStructured<{ items?: WikiDerivedSuggestion[] }>( + buildWikiLlmMessages( + 'Extract durable entity and concept pages from wiki sources. Prefer concise, factual summaries and only include items that deserve their own page.', + [ + `Source title: ${input.title}`, + `Source page id: ${input.sourcePageId}`, + '', + 'Existing wiki titles:', + input.existingTitles.slice(0, 150).join(', '), + '', + 'Source content:', + input.content.slice(0, MAX_INGEST_LLM_CHARS), + ].join('\n'), + ), + schema, + { maxTokens: 1400, temperature: 0.1 }, + context, + ) + return (response.items ?? []) + .filter((item) => item && (item.kind === 'entity' || item.kind === 'concept')) + .map((item) => ({ + name: item.name?.trim() ?? '', + kind: item.kind, + summary: item.summary?.trim() ?? '', + confidence: Math.max(0, Math.min(1, Number.isFinite(Number(item.confidence)) ? Number(item.confidence) : 0)), + existingTitle: item.existingTitle?.trim() || undefined, + })) + .filter((item) => item.name && item.summary && item.confidence >= DERIVED_PAGE_CONFIDENCE_THRESHOLD) +} + +function derivedPagePath(paths: WikiPaths, pageType: WikiPageType, title: string): string { + return path.join(paths.pagesRoot, PAGE_DIRS[pageType], `${slugify(title)}.md`) +} + +async function upsertDerivedPage( + paths: WikiPaths, + sourcePage: { id: string; title: string; sourceUrl?: string; sourcePath?: string }, + item: WikiDerivedSuggestion, + pages: WikiPageSummary[], + context: WikiServiceContext, +): Promise { + const targetTitle = item.existingTitle || item.name + const existing = pages.find((page) => page.type === item.kind && page.title.toLowerCase() === targetTitle.toLowerCase()) + const pageType: WikiPageType = item.kind + const pageId = existing?.id ?? `${PAGE_DIRS[pageType]}-${slugify(item.name)}` + const pagePath = existing?.path ?? derivedPagePath(paths, pageType, item.name) + const blockId = blockIdForSource(sourcePage.id) + const sourceFingerprint = fingerprintSource({ + title: item.name, + content: item.summary, + sourcePath: sourcePage.id, + }) + const contribution = buildDerivedContributionBlock(sourcePage.title, item.summary) + const existingRaw = await fs.readFile(pagePath, 'utf8').catch(() => '') + const existingParsed = existingRaw ? parseFrontmatter(existingRaw) : null + const currentSourceIds = readListFrontmatter(existingParsed?.frontmatter.generatedFromSourceIds) + const nextSourceIds = serializeFrontmatterList([...currentSourceIds, sourcePage.id]) + const nextBody = existingParsed + ? upsertGeneratedBlock( + ensureContributionSection(stripHeading(existingParsed.frontmatter.title || item.name, existingParsed.body)), + blockId, + contribution, + ) + : upsertGeneratedBlock( + [ + generatedDerivedPageIntro(pageType), + '', + '## Source Contributions', + ].join('\n'), + blockId, + contribution, + ) + const nextFrontmatter = existingParsed + ? { + ...existingParsed.frontmatter, + title: existingParsed.frontmatter.title || item.name, + type: existingParsed.frontmatter.type || pageType, + generatedFromSourceIds: nextSourceIds, + updatedAt: nowIso(), + fingerprint: sourceFingerprint, + } + : { + id: pageId, + title: item.name, + type: pageType, + sourceType: 'wiki-generated', + sourceTitle: item.name, + generatedFromSourceIds: nextSourceIds, + sourceUrl: sourcePage.sourceUrl, + sourcePath: sourcePage.sourcePath, + fingerprint: sourceFingerprint, + durability: 'durable', + category: 'reference', + freshnessScore: DEFAULT_FRESHNESS_SCORE, + freshnessStatus: 'fresh', + createdAt: nowIso(), + updatedAt: nowIso(), + } + await fs.writeFile(pagePath, renderMarkdownWithFrontmatter(nextFrontmatter, nextBody), 'utf8') + const memoryId = await syncPageManagedMemory(pagePath, context) + return { + pageId, + pageType, + memoryId, + sourceFingerprint, + created: !existing, + } +} + +async function cleanupRemovedDerivedPages( + previous: WikiIngestStateEntry | undefined, + nextPageIds: Set, + sourcePageId: string, + context: WikiServiceContext, +): Promise { + if (!previous) return + for (const derived of previous.derivedPages) { + if (nextPageIds.has(derived.pageId)) continue + const page = await getWikiPage(derived.pageId, context).catch(() => null) + if (!page) { + if (derived.memoryId) await deleteManagedMemory(derived.memoryId, managedContext(context)).catch(() => undefined) + continue + } + const blockId = blockIdForSource(sourcePageId) + const nextSourceIds = readListFrontmatter(page.frontmatter.generatedFromSourceIds).filter((id) => id !== sourcePageId) + const nextBody = pruneEmptyContributionSection(removeGeneratedBlock(stripHeading(page.title, page.content), blockId)) + const generatedShell = generatedDerivedPageIntro(page.type) + const remainingMeaningfulBody = nextBody + .replace(generatedShell, '') + .replace(/^## Source Contributions\s*$/gm, '') + .trim() + const removePage = !nextBody.trim() + || (nextSourceIds.length === 0 + && page.frontmatter.sourceType === 'wiki-generated' + && !remainingMeaningfulBody) + if (removePage) { + await fs.unlink(page.path).catch(() => undefined) + if (page.memoryIds[0]) { + await deleteManagedMemory(page.memoryIds[0], managedContext(context)).catch(() => undefined) + } + continue + } + const nextFrontmatter = { + ...page.frontmatter, + generatedFromSourceIds: serializeFrontmatterList(nextSourceIds), + updatedAt: nowIso(), + } + await fs.writeFile(page.path, renderMarkdownWithFrontmatter(nextFrontmatter, nextBody), 'utf8') + await syncPageManagedMemory(page.path, context) + } +} + +async function writeIndex(paths: WikiPaths, pages: WikiPageSummary[]): Promise { + const grouped = new Map() + for (const page of pages) { + if (!grouped.has(page.type)) grouped.set(page.type, []) + grouped.get(page.type)!.push(page) + } + const sections = [...grouped.entries()].map(([type, items]) => { + const lines = items.map((page) => `- [[${page.title}]] - ${page.freshnessStatus}, ${page.updatedAt || 'unknown'}`) + return `## ${type}\n\n${lines.join('\n')}` + }) + await fs.writeFile(paths.indexPath, `# Wiki Index\n\n${sections.join('\n\n')}\n`, 'utf8') +} + +async function appendLog(paths: WikiPaths, line: string): Promise { + await fs.appendFile(paths.logPath, `- ${nowIso()} ${line}\n`, 'utf8') +} + +async function runAutoEvolveOnWrite( + context: WikiServiceContext, + warnings: string[], +): Promise { + if (!context.autoEvolveOnWrite) return undefined + try { + return await evolveWiki({ ...context, autoEvolveOnWrite: false }) + } catch { + warnings.push('auto_evolve_failed') + return undefined + } +} + +function managedContext(context: WikiServiceContext): ManagedMemoryContext { + return context.managedMemoryContext ?? { + profileSelection: context.profileSelection, + homeDir: context.homeDir, + platform: context.platform, + } +} + +export async function getWikiStatus(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const pages = summarizeParsedPages(await readParsedPages(context)) + const conflicts = await readJsonFile(paths.conflictsPath, []) + const memoryStore = resolveManagedMemoryStoreContext(managedContext(context)) + return { + profileKey: paths.profileKey, + vaultRoot: paths.vaultRoot, + rawRoot: paths.rawRoot, + pagesRoot: paths.pagesRoot, + metaRoot: paths.metaRoot, + indexPath: paths.indexPath, + logPath: paths.logPath, + schemaPath: paths.schemaPath, + freshnessPath: paths.freshnessPath, + conflictsPath: paths.conflictsPath, + pageCount: pages.length, + sourceCount: pages.reduce((sum, page) => sum + page.sourceCount, 0), + staleCount: pages.filter((page) => page.freshnessStatus === 'stale').length, + conflictCount: conflicts.length, + memory: { + engine: memoryStore.engine, + storagePath: memoryStore.storagePath, + }, + } +} + +export async function listWikiPages(context: WikiServiceContext = {}): Promise { + const paths = resolveWikiPaths(context) + const freshnessMeta = await readJsonFile(paths.freshnessPath, {}) + return summarizeParsedPages(await readParsedPages(context), '', freshnessMeta) +} + +export async function getWikiPage(pageId: string, context: WikiServiceContext = {}): Promise { + const paths = resolveWikiPaths(context) + const freshnessMeta = await readJsonFile(paths.freshnessPath, {}) + const pages = await readParsedPages(context) + const summaries = summarizeParsedPages(pages, '', freshnessMeta) + const summary = summaries.find((page) => page.id === pageId || slugify(page.title) === pageId) + if (!summary) throw new Error(`Wiki page not found: ${pageId}`) + const parsed = pages.find((page) => page.filePath === summary.path) + if (!parsed) throw new Error(`Wiki page file not found: ${pageId}`) + return { + ...summary, + content: parsed.body, + frontmatter: parsed.frontmatter, + citations: parseCitations(parsed.frontmatter), + } +} + +function keywordScore(page: WikiPageSummary, query: string, content: string): number { + const normalized = query.trim().toLowerCase() + if (!normalized) return 0 + const title = page.title.toLowerCase() + const haystack = `${title}\n${content.toLowerCase()}` + let score = 0 + if (title === normalized) score += 100 + if (title.includes(normalized)) score += 45 + if (haystack.includes(normalized)) score += 20 + for (const token of normalized.split(/[^a-z0-9._-]+/).filter(Boolean)) { + if (title.includes(token)) score += 12 + if (haystack.includes(token)) score += 4 + } + return score +} + +function numberFromUnknown(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN + return Number.isFinite(parsed) ? parsed : fallback +} + +function recordRankBoost(metadata: Record, page: WikiPageSummary): number { + const freshness = metadata.freshness && typeof metadata.freshness === 'object' + ? numberFromUnknown((metadata.freshness as Record).score, page.freshnessScore) + : page.freshnessScore + const quality = metadata.quality && typeof metadata.quality === 'object' + ? numberFromUnknown((metadata.quality as Record).confidence, 0.5) + : 0.5 + const accessCount = numberFromUnknown(metadata.access_count, 0) + const durabilityBoost = metadata.durability === 'durable' ? 6 : 0 + const sourceTypeBoost = metadata.sourceType === 'manual' ? 2 : metadata.sourceType === 'url' ? 1 : 0 + const scopeBoost = metadata.scope === 'wiki' ? 8 : 0 + return scopeBoost + durabilityBoost + sourceTypeBoost + freshness * 10 + quality * 8 + Math.log1p(accessCount) +} + +export async function searchWiki( + query: string, + options: { limit?: number } = {}, + context: WikiServiceContext = {}, +): Promise { + const trimmed = query.trim() + if (!trimmed) return [] + const limit = Math.max(1, Math.min(50, options.limit ?? 12)) + const paths = resolveWikiPaths(context) + const freshnessMeta = await readJsonFile(paths.freshnessPath, {}) + const parsedPages = await readParsedPages(context) + const summaries = summarizeParsedPages(parsedPages, trimmed, freshnessMeta) + const contentByPath = new Map(parsedPages.map((page) => [page.filePath, page.body])) + + const results = new Map() + for (const page of summaries) { + const score = keywordScore(page, trimmed, contentByPath.get(page.path) ?? '') + if (score <= 0) continue + results.set(page.id, { + ...page, + score, + matchType: 'keyword', + }) + } + + const semanticHits = await searchManagedMemories(trimmed, { limit }, managedContext(context)).catch(() => []) + for (const hit of semanticHits) { + const metadata = hit.metadata ?? {} + const pageId = typeof metadata.pageId === 'string' ? metadata.pageId : '' + const page = pageId ? summaries.find((item) => item.id === pageId) : undefined + if (!page) continue + const semanticScore = Math.round((hit.score ?? 0) * 100 + recordRankBoost(metadata, page)) + const current = results.get(page.id) + if (!current || semanticScore > current.score) { + results.set(page.id, { + ...page, + score: semanticScore, + matchType: 'semantic', + }) + } + } + + return [...results.values()] + .sort((left, right) => right.score - left.score || right.freshnessScore - left.freshnessScore) + .slice(0, limit) +} + +export async function ingestWikiSource( + input: WikiIngestInput, + context: WikiServiceContext = {}, +): Promise { + const sourceUrlFromContent = !input.sourceUrl?.trim() && !input.sourcePath?.trim() && looksLikeUrl(input.content) + ? input.content!.trim() + : undefined + const ingestInput: WikiIngestInput = sourceUrlFromContent + ? { ...input, sourceUrl: sourceUrlFromContent, content: undefined, sourceType: input.sourceType ?? 'url' } + : input + const isUrl = looksLikeUrl(ingestInput.sourceUrl) + let title = inferTitle(ingestInput) + let content = normalizeContent(ingestInput) + const warnings: string[] = [] + if (isUrl && !ingestInput.confirmUrlIngest) { + return { + state: 'needs_confirmation', + confirmationRequired: true, + message: 'URL ingest requires explicit confirmation. Choose ingest, summarize once, or use for this conversation only.', + pagesCreated: 0, + pagesUpdated: 0, + warnings: ['url_ingest_requires_confirmation'], + } + } + + if (ingestInput.sourceUrl?.trim() && ingestInput.confirmUrlIngest && (!ingestInput.content?.trim() || content === `Source URL: ${ingestInput.sourceUrl.trim()}`)) { + try { + const fetched = await fetchUrlContent(ingestInput.sourceUrl.trim()) + content = fetched.content + if (!ingestInput.title?.trim() && fetched.title) title = fetched.title.slice(0, 120) + } catch { + warnings.push('url_fetch_failed') + content = `Source URL: ${ingestInput.sourceUrl.trim()}` + } + } + + if (!content) { + throw new Error('Wiki ingest requires content or a source URL') + } + + const paths = await ensureWikiVault(context) + const state = await readIngestState(paths) + const pageType = inferPageType(ingestInput) + const knownPages = await listWikiPages(context) + const key = sourceKey(ingestInput, title) + const previous = state.sources[key] + const existingPage = (previous?.primaryPageId || previous?.pageId) + ? knownPages.find((item) => item.id === (previous.primaryPageId || previous.pageId)) + : undefined + const defaultPageId = `${PAGE_DIRS[pageType]}-${slugify(title)}` + const defaultPagePath = path.join(paths.pagesRoot, PAGE_DIRS[pageType], `${slugify(title)}.md`) + const pageId = existingPage?.id ?? previous?.pageId ?? defaultPageId + const pagePath = existingPage?.path ?? defaultPagePath + const fingerprint = fingerprintSource({ + title, + content, + sourceUrl: ingestInput.sourceUrl, + sourcePath: ingestInput.sourcePath, + }) + if (previous?.fingerprint === fingerprint && await pathExists(pagePath)) { + const page = knownPages.find((item) => item.id === previous.pageId) + return { + state: 'skipped', + confirmationRequired: false, + message: 'Wiki source is already current.', + page, + memoryId: previous.memoryId, + pagesCreated: 0, + pagesUpdated: 0, + warnings: [], + } + } + + let derivedSuggestions: WikiDerivedSuggestion[] = [] + let derivedExtractionCompleted = false + if (pageType === 'source') { + if (wikiLlmEnabled(context) && content.length <= MAX_INGEST_LLM_CHARS) { + try { + derivedSuggestions = await extractDerivedSuggestions( + { + title, + content, + sourcePageId: pageId, + existingTitles: knownPages.map((page) => page.title), + }, + context, + ) + derivedExtractionCompleted = true + } catch (error) { + warnings.push('wiki_llm_extract_failed') + logWikiLlmFailure('wiki_llm_extract_failed', error, { title }) + } + } + } + const uniqueSuggestions = [...new Map( + derivedSuggestions.map((item) => [`${item.kind}:${item.name.toLowerCase()}`, item]), + ).values()] + // Drop any legacy `## Extracted Wiki Links` body block left over from earlier + // versions so re-ingest cleans it up; the same information now lives in the + // `relatedPages` YAML frontmatter array, which does not clutter editors. + const relatedPageTitles = uniqueSuggestions + .map((item) => item.existingTitle?.trim() || item.name.trim()) + .filter(Boolean) + const sourceContent = removeGeneratedBlock(content, `${GENERATED_BLOCK_PREFIX}:related-links`) + + const now = nowIso() + const created = await addManagedMemory( + { + content: sourceContent, + metadata: { + sourceType: ingestInput.sourceType || (ingestInput.sourceUrl ? 'url' : ingestInput.sourcePath ? 'file' : 'manual'), + scope: 'wiki', + durability: 'durable', + category: pageType === 'process' ? 'procedure' : 'reference', + provenance: { + sourceType: ingestInput.sourceType || (ingestInput.sourceUrl ? 'url' : ingestInput.sourcePath ? 'file' : 'manual'), + sourcePath: ingestInput.sourcePath, + sourceUrl: ingestInput.sourceUrl, + sourceFingerprint: fingerprint, + importedAt: now, + createdBy: 'user', + }, + quality: { + confidence: 0.8, + recallPriority: pageType === 'source' ? 0.7 : 0.8, + }, + pageId, + sectionId: 'body', + freshness: { + score: DEFAULT_FRESHNESS_SCORE, + status: 'fresh', + updatedAt: now, + }, + lint: { + status: 'unchecked', + }, + }, + }, + managedContext(context), + ) + + if ((previous?.primaryMemoryId || previous?.memoryId) && (previous.primaryMemoryId || previous.memoryId) !== created.memoryId) { + await deleteManagedMemory((previous.primaryMemoryId || previous.memoryId)!, managedContext(context)).catch(() => undefined) + } + + const existed = await pathExists(pagePath) + await fs.writeFile( + pagePath, + renderWikiPage({ + id: pageId, + title, + type: pageType, + sourceType: ingestInput.sourceType || (ingestInput.sourceUrl ? 'url' : ingestInput.sourcePath ? 'file' : 'manual'), + sourcePath: ingestInput.sourcePath, + sourceUrl: ingestInput.sourceUrl, + content: sourceContent, + memoryId: created.memoryId, + fingerprint, + createdAt: existingPage?.createdAt || previous?.updatedAt || now, + updatedAt: now, + relatedPages: relatedPageTitles, + }), + 'utf8', + ) + + const nextDerivedResults: WikiDerivedResult[] = [] + let derivedUpsertFailed = false + // Maintain a local snapshot of known pages and extend it after each + // successful derived upsert so later suggestions in the same batch see pages + // created earlier in the same ingest without a full re-scan. + const mutableKnownPages = [...knownPages] + for (const item of uniqueSuggestions) { + try { + const result = await upsertDerivedPage( + paths, + { + id: pageId, + title, + sourceUrl: ingestInput.sourceUrl, + sourcePath: ingestInput.sourcePath, + }, + item, + mutableKnownPages, + context, + ) + nextDerivedResults.push(result) + if (result.created) { + mutableKnownPages.push({ + id: result.pageId, + title: item.existingTitle || item.name, + type: result.pageType, + path: derivedPagePath(paths, result.pageType, item.name), + relativePath: '', + snippet: '', + sourceCount: 0, + freshnessStatus: 'fresh', + freshnessScore: 1, + lifecycleState: 'just_ingested', + createdAt: nowIso(), + updatedAt: nowIso(), + evolvedAt: '', + evolveCheckedAt: '', + evolveChangedAt: '', + evolveChangeSummary: '', + evolveSource: '', + lastAccessedAt: '', + links: [], + backlinks: [], + memoryIds: result.memoryId ? [result.memoryId] : [], + }) + } + } catch (error) { + derivedUpsertFailed = true + warnings.push(`wiki_derived_page_failed:${item.name}`) + logWikiLlmFailure('wiki_derived_page_failed', error, { name: item.name }) + } + } + + if (derivedExtractionCompleted && !derivedUpsertFailed) { + await cleanupRemovedDerivedPages( + previous, + new Set(nextDerivedResults.map((result) => result.pageId)), + pageId, + context, + ) + } + + const derivedStateResults = derivedExtractionCompleted && !derivedUpsertFailed + ? nextDerivedResults + : (() => { + const merged = new Map() + for (const derived of previous?.derivedPages ?? []) { + merged.set(derived.pageId, { + pageId: derived.pageId, + pageType: derived.pageType, + memoryId: derived.memoryId, + sourceFingerprint: derived.sourceFingerprint, + created: false, + }) + } + for (const result of nextDerivedResults) { + merged.set(result.pageId, result) + } + return [...merged.values()] + })() + + state.sources[key] = { + fingerprint, + pageId, + memoryId: created.memoryId, + primaryPageId: pageId, + primaryMemoryId: created.memoryId, + derivedPageIds: derivedStateResults.map((result) => result.pageId), + memoryIds: [created.memoryId, ...derivedStateResults.map((result) => result.memoryId).filter((memoryId): memoryId is string => Boolean(memoryId))], + derivedPages: derivedStateResults.map((result) => ({ + pageId: result.pageId, + memoryId: result.memoryId, + pageType: result.pageType, + sourceFingerprint: result.sourceFingerprint, + })), + updatedAt: now, + } + await writeIngestState(paths, state) + const pages = await listWikiPages(context) + await writeIndex(paths, pages) + await appendLog(paths, `${existed ? 'Updated' : 'Created'} [[${title}]] from ${ingestInput.sourceUrl || ingestInput.sourcePath || 'manual input'}.`) + const evolve = await runAutoEvolveOnWrite(context, warnings) + const refreshedPages = evolve ? await listWikiPages(context) : pages + const derivedCreated = nextDerivedResults.filter((result) => result.created).length + const derivedUpdated = nextDerivedResults.length - derivedCreated + + return { + state: existed ? 'updated' : 'ingested', + confirmationRequired: false, + message: existed ? 'Wiki article updated.' : 'Wiki article created.', + page: refreshedPages.find((page) => page.id === pageId), + memoryId: created.memoryId, + pagesCreated: (existed ? 0 : 1) + derivedCreated, + pagesUpdated: (existed ? 1 : 0) + derivedUpdated, + warnings, + evolve, + } +} + +function shouldUseWikiForQuestion(query: string): boolean { + return classifyWikiQuestion(query).useWiki +} + +function buildQueryFallbackAnswer(results: WikiSearchResult[]): string { + const top = results.slice(0, 3) + return [ + `Wiki found ${results.length} relevant article${results.length === 1 ? '' : 's'}.`, + ...top.map((page, index) => `${index + 1}. [[${page.title}]] - ${page.snippet}`), + ].join('\n') +} + +function buildQuerySynthesisMessages(query: string, pages: WikiPageDetail[]): WikiLlmMessage[] { + return buildWikiLlmMessages( + 'Answer questions using only the supplied local wiki pages. Cite factual claims with [[Page Title]] wiki links. If the pages are incomplete or contradictory, say so plainly.', + [ + `Question: ${query}`, + '', + ...pages.flatMap((page) => [ + `## [[${page.title}]]`, + `Updated: ${page.updatedAt || 'unknown'}`, + `Freshness: ${page.freshnessStatus}`, + sanitizeContentForSynthesis(page), + '', + ]), + ].join('\n'), + ) +} + +export async function queryWiki( + query: string, + options: { limit?: number } = {}, + context: WikiServiceContext = {}, +): Promise { + const trimmed = query.trim() + if (!trimmed) throw new Error('Wiki query is required') + const results = await searchWiki(trimmed, { limit: options.limit ?? 6 }, context) + const usedWiki = shouldUseWikiForQuestion(trimmed) || results.length > 0 + if (!usedWiki || results.length === 0) { + return { + query: trimmed, + usedWiki: false, + answer: 'No relevant wiki article was found. Use normal conversation context, or ingest sources into the Wiki first.', + results: [], + citations: [], + offerToSave: false, + warnings: [], + } + } + + const topPages = await Promise.all(results.slice(0, 4).map((result) => getWikiPage(result.id, context))) + const citations: WikiCitation[] = [] + for (const page of topPages.slice(0, 3)) { + citations.push(...page.citations) + } + const warnings: string[] = [] + let answer = buildQueryFallbackAnswer(results) + if (wikiLlmEnabled(context)) { + try { + answer = await wikiLlmComplete( + buildQuerySynthesisMessages(trimmed, topPages), + { maxTokens: 1024, temperature: 0.3 }, + context, + ) + } catch (error) { + warnings.push('wiki_llm_query_fallback') + logWikiLlmFailure('wiki_llm_query_fallback', error, { query: trimmed }) + } + } + + return { + query: trimmed, + usedWiki: true, + answer, + results, + citations, + offerToSave: true, + warnings, + } +} + +export async function assistWithWiki( + question: string, + options: { limit?: number } = {}, + context: WikiServiceContext = {}, +): Promise { + const query = question.trim() + if (!query) throw new Error('Wiki assist question is required') + const decision = classifyWikiQuestion(query) + if (!decision.useWiki) { + return { + query, + usedWiki: false, + reason: decision.reason, + answer: 'Wiki context was not used because the question does not appear to require prior knowledge, docs, research, decisions, or project context.', + results: [], + citations: [], + offerToSave: false, + } + } + + return { + ...(await queryWiki(query, options, context)), + reason: decision.reason, + } +} + +export function planWikiLinkChoice(input: string): WikiLinkChoicePayload { + const text = input.trim() + const urls = extractHttpUrls(text) + const actions: WikiLinkChoicePayload['actions'] = [ + { + id: 'ingest', + label: 'Ingest into Wiki', + description: 'Fetch the URL, store provenance in managed PowerMem, and compile or update wiki markdown pages.', + }, + { + id: 'summarize_once', + label: 'Summarize once', + description: 'Use the URL for a one-time answer without writing it into durable Wiki knowledge.', + }, + { + id: 'current_conversation_only', + label: 'Use only now', + description: 'Use the link only as transient context for the current conversation.', + }, + ] + + return { + input: text, + urls, + requiresChoice: urls.length > 0, + defaultAction: 'current_conversation_only', + actions, + message: urls.length > 0 + ? 'A pasted link needs an explicit choice before Wiki ingestion.' + : 'No HTTP URL was detected.', + } +} + +function importantTokens(query: string): string[] { + const stopWords = new Set([ + 'about', + 'after', + 'again', + 'against', + 'all', + 'and', + 'are', + 'can', + 'for', + 'from', + 'how', + 'into', + 'know', + 'the', + 'this', + 'what', + 'when', + 'where', + 'wiki', + 'with', + ]) + return query + .toLowerCase() + .split(/[^a-z0-9]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 1 && !stopWords.has(token)) +} + +function titleCaseTopic(value: string): string { + const cleaned = value + .replace(/\bwhat\s+do\s+we\s+know\s+about\b/gi, '') + .replace(/\bwiki\b/gi, '') + .replace(/[?!.,:;]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + const topic = cleaned || value.trim() || 'Wiki Synthesis' + return topic.split(/\s+/).map((word) => { + const upper = word.toUpperCase() + if (['AI', 'API', 'LLM', 'MCP', 'RAG'].includes(upper)) return upper + return `${word.slice(0, 1).toUpperCase()}${word.slice(1).toLowerCase()}` + }).join(' ') +} + +function splitSentences(content: string): string[] { + return content + .replace(/^# .+$/gm, ' ') + .replace(/\s+/g, ' ') + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter((sentence) => sentence.length >= 50 && sentence.length <= 360) +} + +function scoreSentence(sentence: string, tokens: string[]): number { + const lower = sentence.toLowerCase() + return tokens.reduce((score, token) => score + (lower.includes(token) ? 1 : 0), 0) +} + +function selectSynthesisBullets(sourcePages: WikiPageDetail[], query: string): string[] { + const tokens = importantTokens(query) + const candidates = sourcePages.flatMap((page) => ( + splitSentences(sanitizeContentForSynthesis(page)).map((sentence) => ({ + sentence, + page, + score: scoreSentence(sentence, tokens), + })) + )) + const seen = new Set() + return candidates + .filter((candidate) => candidate.score > 0) + .sort((left, right) => right.score - left.score || left.sentence.length - right.sentence.length) + .filter((candidate) => { + const key = candidate.sentence.toLowerCase().replace(/[^a-z0-9]+/g, ' ').slice(0, 120) + if (seen.has(key)) return false + seen.add(key) + return true + }) + .slice(0, 6) + .map((candidate) => `- ${candidate.sentence} ([[${candidate.page.title}]])`) +} + +function renderSynthesisBody(input: { + title: string + query: string + sourcePages: WikiPageDetail[] +}): string { + const bullets = selectSynthesisBullets(input.sourcePages, input.query) + const sourceNotes = input.sourcePages.map((page) => `- [[${page.title}]]: ${makeSnippet(sanitizeContentForSynthesis(page))}`) + return [ + '## Generated Synthesis', + '', + `This page synthesizes Wiki sources for: **${input.query}**.`, + '', + '## Key Points', + '', + ...(bullets.length > 0 ? bullets : ['- The available sources were relevant but did not expose enough extractable focused claims for a stronger synthesis.']), + '', + '## Source Notes', + '', + ...sourceNotes, + ].join('\n') +} + +function resolveSynthesisTitle( + requestedTitle: string | undefined, + query: string, + pages: WikiPageSummary[], +): string { + const baseTitle = titleCaseTopic(requestedTitle?.trim() || query) + if (requestedTitle?.trim()) return baseTitle + const occupiedTitles = new Set( + pages + .filter((page) => page.type !== 'synthesis') + .map((page) => page.title.toLowerCase()), + ) + if (!occupiedTitles.has(baseTitle.toLowerCase())) return baseTitle + + const candidates = [ + `Synthesis ${baseTitle}`, + `${baseTitle} Synthesis`, + ] + for (const candidate of candidates) { + if (!occupiedTitles.has(candidate.toLowerCase())) return candidate + } + + let suffix = 2 + while (occupiedTitles.has(`${baseTitle} Synthesis ${suffix}`.toLowerCase())) { + suffix += 1 + } + return `${baseTitle} Synthesis ${suffix}` +} + +export async function synthesizeWiki( + input: WikiSynthesizeInput, + context: WikiServiceContext = {}, +): Promise { + const query = input.query.trim() + if (!query) throw new Error('Wiki synthesis query is required') + const limit = Math.max(1, Math.min(8, input.limit ?? 5)) + const results = await searchWiki(query, { limit }, context) + const title = resolveSynthesisTitle(input.title, query, results) + const pageId = `synthesis-${slugify(title)}` + const sourceResults = results.filter((result) => result.id !== pageId) + if (sourceResults.length === 0) throw new Error('Wiki synthesis requires at least one matching source') + + const sourcePages = await Promise.all(sourceResults.slice(0, limit).map((result) => getWikiPage(result.id, context))) + const paths = await ensureWikiVault(context) + const pagePath = path.join(paths.pagesRoot, PAGE_DIRS.synthesis, `${slugify(title)}.md`) + const previous = await pathExists(pagePath) ? await getWikiPage(pageId, context).catch(() => null) : null + const content = renderSynthesisBody({ title, query, sourcePages }) + const fingerprint = fingerprintSource({ + title, + content, + sourcePath: sourcePages.map((page) => page.id).join('|'), + }) + const now = nowIso() + const created = await addManagedMemory( + { + content, + metadata: { + sourceType: 'synthesis', + scope: 'wiki', + durability: 'durable', + category: 'synthesis', + provenance: { + sourceType: 'synthesis', + sourcePageIds: sourcePages.map((page) => page.id), + sourceFingerprint: fingerprint, + importedAt: now, + createdBy: 'wiki-synthesis', + }, + quality: { + confidence: 0.72, + recallPriority: 0.85, + }, + pageId, + sectionId: 'body', + freshness: { + score: DEFAULT_FRESHNESS_SCORE, + status: 'fresh', + updatedAt: now, + }, + lint: { + status: 'unchecked', + }, + }, + }, + managedContext(context), + ) + if (previous?.memoryIds[0] && previous.memoryIds[0] !== created.memoryId) { + await deleteManagedMemory(previous.memoryIds[0], managedContext(context)).catch(() => undefined) + } + + const existed = await pathExists(pagePath) + await fs.writeFile( + pagePath, + renderGeneratedWikiPage({ + id: pageId, + title, + type: 'synthesis', + content, + memoryId: created.memoryId, + fingerprint, + sourcePages, + createdAt: previous?.frontmatter.createdAt || now, + updatedAt: now, + }), + 'utf8', + ) + const pages = await listWikiPages(context) + await writeIndex(paths, pages) + await appendLog(paths, `${existed ? 'Updated' : 'Created'} generated synthesis [[${title}]] from ${sourcePages.length} source page(s).`) + const citations = sourcePages.flatMap((sourcePage) => sourcePage.citations) + const warnings: string[] = citations.length === 0 ? ['synthesis_has_no_source_citations'] : [] + const evolve = await runAutoEvolveOnWrite(context, warnings) + const refreshedPages = evolve ? await listWikiPages(context) : pages + const page = refreshedPages.find((item) => item.id === pageId) + if (!page) throw new Error(`Generated wiki page was not indexed: ${pageId}`) + return { + title, + query, + page, + memoryId: created.memoryId, + pagesCreated: existed ? 0 : 1, + pagesUpdated: existed ? 1 : 0, + sourcePageIds: sourcePages.map((sourcePage) => sourcePage.id), + citations, + warnings, + evolve, + } +} + +function contradictionPairs(pages: WikiPageSummary[]): Array<[WikiPageSummary, WikiPageSummary]> { + const pairs: Array<[WikiPageSummary, WikiPageSummary]> = [] + const seen = new Set() + const titleToId = new Map() + for (const page of pages) { + titleToId.set(page.id, page.id) + titleToId.set(page.title, page.id) + } + const resolveLinks = (page: WikiPageSummary): Set => new Set( + page.links.map((link) => titleToId.get(link) ?? link).concat(page.backlinks), + ) + for (let index = 0; index < pages.length; index += 1) { + const left = pages[index]! + const leftLinks = resolveLinks(left) + for (let otherIndex = index + 1; otherIndex < pages.length; otherIndex += 1) { + const right = pages[otherIndex]! + const rightLinks = resolveLinks(right) + const related = [...rightLinks].some((link) => leftLinks.has(link)) + || leftLinks.has(right.id) + || rightLinks.has(left.id) + if (!related) continue + const key = [left.id, right.id].sort().join('::') + if (seen.has(key)) continue + seen.add(key) + pairs.push([left, right]) + if (pairs.length >= MAX_CONTRADICTION_PAIRS) return pairs + } + } + return pairs +} + +async function detectContradictions( + pages: WikiPageSummary[], + context: WikiServiceContext, +): Promise<{ issues: WikiLintIssue[]; warnings: string[] }> { + if (!wikiLlmEnabled(context)) return { issues: [], warnings: [] } + const warnings: string[] = [] + const pairs = contradictionPairs(pages) + const issues: WikiLintIssue[] = [] + for (const [left, right] of pairs) { + try { + const leftPage = await getWikiPage(left.id, context) + const rightPage = await getWikiPage(right.id, context) + const leftContent = sanitizeContentForSynthesis(leftPage).slice(0, MAX_CONTRADICTION_PAGE_CHARS) + const rightContent = sanitizeContentForSynthesis(rightPage).slice(0, MAX_CONTRADICTION_PAGE_CHARS) + const result = await wikiLlmCompleteStructured<{ + contradictions?: Array<{ claim1?: string; claim2?: string; explanation?: string }> + }>( + buildWikiLlmMessages( + 'Compare two knowledge pages and report factual contradictions only. Return an empty contradictions array when the pages are compatible.', + [ + `Page 1: [[${leftPage.title}]]`, + leftContent, + '', + `Page 2: [[${rightPage.title}]]`, + rightContent, + ].join('\n'), + ), + { + contradictions: [{ claim1: 'string', claim2: 'string', explanation: 'string' }], + }, + { maxTokens: 900, temperature: 0.1 }, + context, + ) + for (const contradiction of result.contradictions ?? []) { + if (!contradiction?.explanation?.trim()) continue + issues.push({ + id: `contradiction:${left.id}:${right.id}:${issues.length}`, + severity: 'warning', + kind: 'contradiction', + pageId: left.id, + title: 'Potential contradiction', + detail: `${left.title} vs ${right.title}: ${contradiction.explanation.trim()}`, + }) + } + } catch (error) { + warnings.push('wiki_llm_contradiction_check_failed') + logWikiLlmFailure('wiki_llm_contradiction_check_failed', error, { + left: left.id, + right: right.id, + }) + break + } + } + return { issues, warnings } +} + +export async function lintWiki( + context: WikiServiceContext = {}, + options: { detectContradictions?: boolean } = {}, +): Promise { + const paths = await ensureWikiVault(context) + const parsed = await readParsedPages(context) + const pages = summarizeParsedPages(parsed) + const issues = computeWikiLintIssues(parsed, pages) + const contradictionCheck = options.detectContradictions === false + ? { issues: [], warnings: [] as string[] } + : await detectContradictions(pages, context) + const allIssues = issues.concat(contradictionCheck.issues) + + await writeJsonFile(paths.conflictsPath, allIssues.filter(isWikiConflictIssue)) + return { + checkedAt: nowIso(), + issueCount: allIssues.length, + issues: allIssues, + warnings: contradictionCheck.warnings, + } +} + +function isWikiConflictIssue(issue: WikiLintIssue): boolean { + return issue.kind === 'duplicate-title' || issue.kind === 'missing-link' || issue.kind === 'stale' || issue.kind === 'contradiction' +} + +function computeWikiLintIssues(parsed: ParsedPage[], pages: WikiPageSummary[]): WikiLintIssue[] { + const issues: WikiLintIssue[] = [] + const titleCounts = new Map() + for (const page of pages) { + const key = page.title.toLowerCase() + if (!titleCounts.has(key)) titleCounts.set(key, []) + titleCounts.get(key)!.push(page) + + if (page.links.length === 0 && page.backlinks.length === 0) { + issues.push({ + id: `orphan:${page.id}`, + severity: 'warning', + kind: 'orphan', + pageId: page.id, + title: 'Orphan page', + detail: `${page.title} has no wiki links or backlinks.`, + }) + } + if (page.freshnessStatus === 'stale') { + issues.push({ + id: `stale:${page.id}`, + severity: 'warning', + kind: 'stale', + pageId: page.id, + title: 'Stale page', + detail: `${page.title} has a stale freshness score.`, + }) + } + } + + const titles = new Set(pages.map((page) => page.title).concat(pages.map((page) => page.id))) + for (const page of pages) { + for (const link of page.links) { + if (titles.has(link)) continue + issues.push({ + id: `missing:${page.id}:${slugify(link)}`, + severity: 'warning', + kind: 'missing-link', + pageId: page.id, + title: 'Missing linked page', + detail: `${page.title} links to missing page ${link}.`, + }) + } + } + + for (const [title, matches] of titleCounts.entries()) { + if (matches.length < 2) continue + issues.push({ + id: `duplicate:${slugify(title)}`, + severity: 'error', + kind: 'duplicate-title', + title: 'Duplicate title', + detail: `${matches.length} wiki pages share the title ${matches[0]!.title}.`, + }) + } + + for (const parsedPage of parsed) { + const title = parsedPage.frontmatter.title + const id = parsedPage.frontmatter.id + if (!title || !id) { + issues.push({ + id: `schema:${parsedPage.relativePath}`, + severity: 'error', + kind: 'schema', + title: 'Schema violation', + detail: `${parsedPage.relativePath} is missing required id or title frontmatter.`, + }) + } + } + + return issues +} + +export async function evolveWiki(context: WikiServiceContext = {}): Promise { + const paths = await ensureWikiVault(context) + const parsed = await readParsedPages(context) + const evolvedAt = nowIso() + const freshness: WikiEvolvePayload['freshness'] = {} + const changedPageIds: string[] = [] + const related: Record = {} + + for (const page of parsed) { + const updatedAt = page.frontmatter.updatedAt || evolvedAt + const lastAccessedAt = page.frontmatter.lastAccessedAt || updatedAt + const updatedMs = Date.parse(updatedAt) + const evolvedMs = Date.parse(evolvedAt) + const ageDays = Number.isFinite(updatedMs) + ? Math.max(0, ((Number.isFinite(evolvedMs) ? evolvedMs : Date.now()) - updatedMs) / 86_400_000) + : 0 + const importance = parseNumber(page.frontmatter.importance, 0.7) + const decayRate = 0.16 * (1 - importance * 0.8) + const score = Math.max(0, Math.min(1, importance * Math.exp(-decayRate * ageDays))) + const roundedScore = Number(score.toFixed(4)) + const status = ageDays < 1 ? 'fresh' : freshnessStatus(roundedScore) + const id = page.frontmatter.id || slugify(page.frontmatter.title || page.relativePath) + freshness[id] = { + score: roundedScore, + status, + lastAccessedAt, + updatedAt, + checkedAt: evolvedAt, + } + related[id] = [] + } + + const summaries = summarizeParsedPages(parsed) + const summaryById = new Map(summaries.map((page) => [page.id, page])) + for (const page of summaries) { + const linked = new Set() + for (const link of page.links.concat(page.backlinks)) { + const target = summaryById.get(link) ?? summaries.find((candidate) => candidate.title === link) + if (target && target.id !== page.id) linked.add(target.id) + } + related[page.id] = [...linked].sort() + } + + const currentIssues = computeWikiLintIssues(parsed, summaries) + const conflicts = currentIssues.filter(isWikiConflictIssue) + const issuesByPage = new Map() + for (const issue of conflicts) { + if (!issue.pageId) continue + issuesByPage.set(issue.pageId, [...(issuesByPage.get(issue.pageId) ?? []), issue]) + } + + for (const page of parsed) { + const id = page.frontmatter.id || slugify(page.frontmatter.title || page.relativePath) + const item = freshness[id] + if (!item) continue + const previousScore = parseNumber(page.frontmatter.freshnessScore, DEFAULT_FRESHNESS_SCORE) + const previousStatus = page.frontmatter.freshnessStatus + const previousRelated = page.frontmatter.relatedPageIds || '' + const nextRelated = (related[id] ?? []).join('|') + const pageIssues = issuesByPage.get(id) ?? [] + const previousIssueCount = parseNumber(page.frontmatter.evolveIssueCount, 0) + const changes: string[] = [] + + if (previousStatus && previousStatus !== item.status) { + changes.push(`Freshness changed from ${previousStatus} to ${item.status}.`) + } else if (!previousStatus) { + changes.push(`Freshness initialized as ${item.status}.`) + } + if (Math.abs(previousScore - item.score) >= 0.01) { + changes.push(`Freshness score changed from ${Number(previousScore.toFixed(4))} to ${item.score}.`) + } + if (previousRelated !== nextRelated) { + changes.push(nextRelated ? `Related pages updated: ${nextRelated}.` : 'Related pages cleared.') + } + if (previousIssueCount !== pageIssues.length) { + changes.push(pageIssues.length > 0 ? `Health issues detected: ${pageIssues.map((issue) => issue.kind).join(', ')}.` : 'Health issues cleared.') + } + if (!page.frontmatter.evolveSource) { + changes.push('Evolution evidence initialized.') + } + + const changedByEvolve = changes.length > 0 + const nextFrontmatter = { + ...page.frontmatter, + freshnessScore: item.score, + freshnessStatus: item.status, + lastAccessedAt: item.lastAccessedAt, + relatedPageIds: nextRelated, + evolveIssueCount: pageIssues.length, + evolveSource: 'freshness-score, wiki-links, backlinks, lint-health', + ...(changedByEvolve + ? { + evolveChangedAt: evolvedAt, + evolveChangeSummary: changes.join(' '), + } + : {}), + } + if ( + changedByEvolve || + Math.abs(previousScore - item.score) >= 0.01 || + page.frontmatter.freshnessStatus !== item.status || + page.frontmatter.relatedPageIds !== nextRelated || + previousIssueCount !== pageIssues.length + ) { + await fs.writeFile(page.filePath, renderMarkdownWithFrontmatter(nextFrontmatter, page.body), 'utf8') + if (changedByEvolve) changedPageIds.push(id) + } + } + + const evolvedParsed = await readParsedPages(context) + const evolvedPages = summarizeParsedPages(evolvedParsed, '', freshness) + const evolvedIssues = computeWikiLintIssues(evolvedParsed, evolvedPages) + const evolvedConflicts = evolvedIssues.filter(isWikiConflictIssue) + await writeJsonFile(paths.freshnessPath, freshness) + await writeJsonFile(paths.conflictsPath, evolvedConflicts) + await writeJsonFile(path.join(paths.metaRoot, 'related.json'), related) + await writeIndex(paths, evolvedPages) + await appendLog(paths, `Evolved ${Object.keys(freshness).length} wiki page(s); ${changedPageIds.length} markdown page(s) updated.`) + + return { + mode: 'mechanical', + evolvedAt, + pageCount: Object.keys(freshness).length, + staleCount: Object.values(freshness).filter((item) => item.status === 'stale').length, + conflictCount: evolvedConflicts.length, + changedPageIds, + related, + warnings: evolvedConflicts.length > 0 ? ['wiki_conflicts_detected'] : [], + freshness, + } +} + +async function reviseStalePageWithLlm( + page: WikiPageDetail, + relatedPages: WikiPageDetail[], + context: WikiServiceContext, +): Promise<{ changed: boolean; warning?: string }> { + try { + const rawRevision = await wikiLlmComplete( + buildWikiLlmMessages( + 'Review a stale wiki page against related context. Treat obviously stale, placeholder, or low-information text such as "pending verification", "TBD", or generic status notes as needing revision when related pages provide stronger facts. Reply exactly NO_CHANGE only if the current page is already specific, current, and materially supported by the related context. Otherwise return only the revised markdown body for the current page with no JSON, no frontmatter, no code fences, and no commentary.', + [ + `Current page: [[${page.title}]]`, + page.content, + '', + ...relatedPages.flatMap((item) => [ + `Related page: [[${item.title}]]`, + item.content, + '', + ]), + ].join('\n'), + ), + { maxTokens: 1800, temperature: 0.15 }, + context, + ) + const normalizedRevision = rawRevision.trim() + if (!normalizedRevision || /^NO_CHANGE\b/i.test(normalizedRevision)) return { changed: false } + const unfencedRevision = normalizedRevision + .replace(/^```[a-zA-Z0-9_-]*\s*/, '') + .replace(/\s*```$/, '') + .trim() + const nextBody = stripHeading(page.title, unfencedRevision).trim() + if (!nextBody) return { changed: false } + const strippedCurrent = stripHeading(page.title, page.content) + if (nextBody === strippedCurrent) return { changed: false } + const nextFrontmatter = { + ...page.frontmatter, + updatedAt: nowIso(), + evolveChangedAt: nowIso(), + evolveChangeSummary: 'LLM deep evolve revised a stale wiki page.', + evolveSource: 'llm-deep-evolve', + freshnessScore: 1, + freshnessStatus: 'fresh', + } + await fs.writeFile(page.path, renderMarkdownWithFrontmatter(nextFrontmatter, nextBody), 'utf8') + await syncPageManagedMemory(page.path, context) + return { changed: true } + } catch (error) { + logWikiLlmFailure('wiki_llm_deep_evolve_failed', error, { pageId: page.id }) + return { changed: false, warning: `wiki_llm_deep_evolve_failed:${page.id}` } + } +} + +async function collectDeepEvolveRelatedPages( + page: WikiPageDetail, + context: WikiServiceContext, +): Promise { + const queue = readListFrontmatter(page.frontmatter.relatedPageIds) + const seen = new Set([page.id]) + const relatedPages: WikiPageDetail[] = [] + + while (queue.length > 0 && relatedPages.length < 4) { + const relatedId = queue.shift() + if (!relatedId || seen.has(relatedId)) continue + seen.add(relatedId) + const relatedPage = await getWikiPage(relatedId, context).catch(() => null) + if (!relatedPage) continue + relatedPages.push(relatedPage) + for (const nestedId of readListFrontmatter(relatedPage.frontmatter.relatedPageIds)) { + if (!seen.has(nestedId)) queue.push(nestedId) + } + } + + return relatedPages +} + +export async function evolveWikiDeep(context: WikiServiceContext = {}): Promise { + const base = await evolveWiki({ ...context, autoEvolveOnWrite: false }) + if (!wikiLlmEnabled(context)) { + return { + ...base, + mode: 'deep', + warnings: [...base.warnings, 'wiki_llm_disabled'], + } + } + + const staleIds = Object.entries(base.freshness) + .filter(([, item]) => item.status === 'stale') + .map(([pageId]) => pageId) + .slice(0, MAX_DEEP_EVOLVE_PAGES) + const warnings = [...base.warnings] + const changedPageIds = [...base.changedPageIds] + + for (const staleId of staleIds) { + const page = await getWikiPage(staleId, context).catch(() => null) + if (!page) continue + const relatedPages = await collectDeepEvolveRelatedPages(page, context) + const revision = await reviseStalePageWithLlm( + page, + relatedPages, + context, + ) + if (revision.warning) warnings.push(revision.warning) + if (revision.changed) { + base.freshness[page.id] = { + score: 1, + status: 'fresh', + lastAccessedAt: page.lastAccessedAt, + updatedAt: nowIso(), + checkedAt: nowIso(), + } + if (!changedPageIds.includes(page.id)) changedPageIds.push(page.id) + } + } + + const paths = resolveWikiPaths(context) + await writeJsonFile(paths.freshnessPath, base.freshness) + const lint = await lintWiki(context, { detectContradictions: false }) + const finalFreshness = base.freshness + const finalPages = summarizeParsedPages(await readParsedPages(context), '', finalFreshness) + const finalRelated: Record = {} + const finalById = new Map(finalPages.map((page) => [page.id, page])) + for (const page of finalPages) { + const linked = new Set() + for (const link of page.links.concat(page.backlinks)) { + const target = finalById.get(link) ?? finalPages.find((candidate) => candidate.title === link) + if (target && target.id !== page.id) linked.add(target.id) + } + finalRelated[page.id] = [...linked].sort() + } + await writeJsonFile(path.join(paths.metaRoot, 'related.json'), finalRelated) + await writeJsonFile(paths.conflictsPath, lint.issues.filter(isWikiConflictIssue)) + await writeIndex(paths, finalPages) + await appendLog(paths, `Deep evolved ${staleIds.length} stale wiki page candidate(s); ${changedPageIds.length - base.changedPageIds.length} page(s) revised by LLM.`) + + return { + ...base, + mode: 'deep', + pageCount: finalPages.length, + staleCount: Object.values(finalFreshness).filter((item) => item.status === 'stale').length, + conflictCount: lint.issues.filter(isWikiConflictIssue).length, + changedPageIds, + related: finalRelated, + warnings, + freshness: finalFreshness, + } +} + +export async function recentWikiManagedMemories( + limit = 20, + context: WikiServiceContext = {}, +) { + const listed = await listManagedMemories({ limit }, managedContext(context)) + return listed.memories.filter((memory) => typeof memory.metadata.pageId === 'string') +} diff --git a/packages/web/package.json b/packages/web/package.json index 3a439f49..ad8423e3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw-manager/web", - "version": "0.3.1", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -32,7 +32,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "jsdom": "^29.0.1", - "postcss": "^8.4.49", + "postcss": "^8.5.14", "tailwindcss": "^3.4.17", "typescript": "~5.6.3", "vite": "^6.0.5", diff --git a/packages/web/src/app/Layout.tsx b/packages/web/src/app/Layout.tsx index 5444f068..613ae2d1 100644 --- a/packages/web/src/app/Layout.tsx +++ b/packages/web/src/app/Layout.tsx @@ -7,6 +7,7 @@ import type { GatewayStatus } from '@/lib/types' import { getGatewayStatusResult } from '@/shared/adapters/gateway' import { isWindowsHostPlatform } from '@/shared/hostPlatform' import { platformResults } from '@/shared/adapters/platformResults' +import { checkClawmasterReleaseResult } from '@/shared/adapters/clawmasterReleases' import { getClawModules } from './moduleRegistry' import { CommandPalette, type CommandEntry } from './CommandPalette' import { getCommandDescriptors } from './commandRegistry' @@ -63,9 +64,10 @@ interface LayoutProps { type UpdateBannerState = | { status: 'idle' | 'checking' | 'unavailable' | 'up-to-date' | 'error' } - | { status: 'available'; currentVersion: string; latestVersion: string } + | { status: 'available'; currentVersion: string; latestVersion: string; source: 'github' | 'npm' } const COMMAND_HINT_DISMISSED_KEY = 'clawmaster-command-palette-hint-dismissed' +const CLAWMASTER_RELEASE_DISMISSED_PREFIX = 'clawmaster-release-dismissed:' const HASH_SCROLL_OBSERVER_TIMEOUT_MS = 10_000 function isEditableEventTarget(target: EventTarget | null): boolean { @@ -101,10 +103,8 @@ function decodeHashTargetId(hashValue: string): string | null { } } -function normalizeVersion(version: string | undefined): string { - const raw = String(version ?? '').replace(/^v/i, '').trim() - const match = raw.match(/\d+\.\d+\.\d+[\w.-]*/) - return match ? match[0] : raw +function clawmasterReleaseDismissedKey(version: string): string { + return `${CLAWMASTER_RELEASE_DISMISSED_PREFIX}${version}` } export default function Layout({ children }: LayoutProps) { @@ -138,40 +138,54 @@ export default function Layout({ children }: LayoutProps) { useEffect(() => { let cancelled = false - async function checkForOpenclawUpdate() { - setUpdateBanner({ status: 'checking' }) - + async function detectHostPlatform() { const system = await platformResults.detectSystem() const detectedHostPlatform = system.data?.runtime?.hostPlatform - if (detectedHostPlatform) { + if (!cancelled && detectedHostPlatform) { setHostPlatform(detectedHostPlatform) } - const currentVersion = normalizeVersion(system.data?.openclaw.version) - if (!system.success || !system.data?.openclaw.installed || !currentVersion) { - if (!cancelled) setUpdateBanner({ status: 'unavailable' }) - return - } + } - const versions = await platformResults.listOpenclawNpmVersions() - const latestVersion = normalizeVersion( - versions.data?.distTags.latest ?? versions.data?.versions[0] - ) + void detectHostPlatform() + return () => { + cancelled = true + } + }, []) - if (!versions.success || !latestVersion) { + useEffect(() => { + let cancelled = false + + async function checkForClawmasterUpdate() { + setUpdateBanner({ status: 'checking' }) + + const result = await checkClawmasterReleaseResult() + if (!result.success || !result.data?.latestVersion) { if (!cancelled) setUpdateBanner({ status: 'error' }) return } + if (!result.data.hasUpdate) { + if (!cancelled) setUpdateBanner({ status: 'up-to-date' }) + return + } + + const dismissed = + localStorage.getItem(clawmasterReleaseDismissedKey(result.data.latestVersion)) === '1' if (!cancelled) { setUpdateBanner( - currentVersion === latestVersion + dismissed ? { status: 'up-to-date' } - : { status: 'available', currentVersion, latestVersion }, + : { + status: 'available', + currentVersion: result.data.currentVersion, + latestVersion: result.data.latestVersion, + source: result.data.source, + }, ) } } - void checkForOpenclawUpdate() + void checkForClawmasterUpdate() return () => { cancelled = true } @@ -183,6 +197,13 @@ export default function Layout({ children }: LayoutProps) { } }, [updateBanner]) + function dismissUpdateBanner() { + if (updateBanner.status === 'available') { + localStorage.setItem(clawmasterReleaseDismissedKey(updateBanner.latestVersion), '1') + } + setUpdateBannerDismissed(true) + } + const modules = getClawModules() const navItems: NavItem[] = useMemo(() => modules.map((m) => ({ @@ -493,11 +514,11 @@ export default function Layout({ children }: LayoutProps) { {t('layout.update.available', { version: updateBanner.latestVersion })}

- + {t('layout.update.action')} @@ -961,6 +1017,15 @@ export default function Channels() { {t('channel.qr.start')} )} + {wechatSetupStage === 'timedOut' && ( + + )} {wechatSetupStage === 'error' && wechatPluginInstalled && ( ) : null}

{job.id}

@@ -598,7 +643,7 @@ export default function CronPage() { closeRunsPanel() return } - void handleLoadRuns(job) + void openRunsPanel(job) }} className="button-secondary px-3 py-1.5 text-sm" disabled={!gatewayReady} @@ -876,6 +921,18 @@ export default function CronPage() {
+ {configuredChannelAccountCount === 0 ? ( +
+

{t('cron.noChannelsWarningTitle')}

+

+ {t('cron.noChannelsWarningBody')}{' '} + + {t('cron.openChannels')} + +

+
+ ) : null} +
+ +
+
+ +

+ {t('gateway.safeguard')} +

+
+

{watchdogSummary}

+
+
+

{t('gateway.safeguardState')}

+

{watchdogStateLabel}

+
+
+

{t('gateway.safeguardRestarts')}

+

{watchdog?.restartCount ?? 0}

+
+
+ {watchdog?.lastError ? ( +

{watchdog.lastError}

+ ) : null} +
@@ -229,7 +272,7 @@ export default function Gateway() {

- {status?.running ? t('gateway.openInBrowser') : t('gateway.editConfigHint')} + {watchdog?.enabled ? t('gateway.safeguardHelp') : status?.running ? t('gateway.openInBrowser') : t('gateway.editConfigHint')}

)} @@ -258,7 +301,7 @@ export default function Gateway() { {gatewayToken && (
-

Token

+

{t('gateway.token')}

)} diff --git a/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx b/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx index 4f2b6afe..3009ff47 100644 --- a/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx +++ b/packages/web/src/modules/gateway/__tests__/GatewayPage.test.tsx @@ -123,6 +123,30 @@ describe('GatewayPage', () => { expect(await screen.findByText('Token copied')).toBeInTheDocument() }) + it('shows the service watchdog safeguard state when available', async () => { + mockGetGatewayStatus.mockResolvedValue({ + success: true, + data: { + running: true, + port: 18789, + watchdog: { + enabled: true, + state: 'healthy', + intervalMs: 30000, + restartCount: 2, + lastCheckAt: '2026-04-28T00:00:00.000Z', + }, + }, + }) + + renderGatewayPage() + + expect((await screen.findAllByText('Auto-restart enabled')).length).toBeGreaterThan(0) + expect(screen.getByText('Healthy')).toBeInTheDocument() + expect(screen.getByText('ClawMaster service monitors OpenClaw gateway and restarts it after unexpected downtime.')).toBeInTheDocument() + expect(screen.getAllByText('2').length).toBeGreaterThan(0) + }) + it('starts a stopped gateway and refreshes the runtime controls', async () => { mockGetGatewayStatus .mockResolvedValueOnce({ success: true, data: { running: false, port: 18789 } }) diff --git a/packages/web/src/modules/models/ModelsPage.tsx b/packages/web/src/modules/models/ModelsPage.tsx index 27fbcc73..d22d3281 100644 --- a/packages/web/src/modules/models/ModelsPage.tsx +++ b/packages/web/src/modules/models/ModelsPage.tsx @@ -259,7 +259,7 @@ export default function Models() { const [, setModels] = useState([]) const [loading, setLoading] = useState(true) const [showAdd, setShowAdd] = useState(false) - const [preferredProvider, setPreferredProvider] = useState('baidu-aistudio') + const [preferredProvider, setPreferredProvider] = useState('baiduqianfancodingplan') const loadData = useCallback(async () => { try { @@ -314,7 +314,7 @@ export default function Models() { + + +
+
+
+

{t('settings.currentLabel')}

+

v{CLAWMASTER_VERSION}

+
+
+

{t('settings.clawmasterLatest')}

+

+ {latestVersion ? `v${latestVersion}` : t('common.notSet')} +

+ {releaseDate ?

{releaseDate}

: null} +
+
+ + {state === 'error' && error ? ( +
+ + {error} +
+ ) : null} + + {state === 'ready' && releaseCheck ? ( +
+
+ {releaseCheck.hasUpdate ? ( + + + {t('settings.clawmasterUpdateAvailable', { version: latestVersion })} + + ) : ( + + + {t('settings.clawmasterUpToDate')} + + )} + + {releaseCheck.source === 'github' + ? t('settings.clawmasterSourceGithub') + : t('settings.clawmasterSourceNpm')} + +
+ + {bodyPreview ? ( +
+ {renderReleaseMarkdown(`${bodyPreview}${latestRelease && latestRelease.body.length > bodyPreview.length ? '\n...' : ''}`)} +
+ ) : ( +

{t('settings.clawmasterNpmFallbackDesc')}

+ )} + +
+ + {latestRelease?.htmlUrl && installer ? ( + + ) : null} +
+
+ ) : null} +
+ + ) +} + function UpdateSection({ currentVersion, installed, @@ -1270,7 +1605,11 @@ function UpdateSection({ const upToDate = currentVersion && currentVersion.includes(latest) setState(upToDate ? 'up-to-date' : 'available') } else { - setError(result.error ?? t('common.unknownError')) + setError( + isBrowserFetchFailure(result.error) + ? t('settings.updateBackendUnavailable') + : result.error ?? t('common.unknownError') + ) setState('error') } }, [currentVersion, t]) @@ -1310,29 +1649,31 @@ function UpdateSection({ }, [handleCheck, location.hash, state, updateTask.status]) return ( -
-
-

{t('settings.update')}

-
-
- {/* Current version */} -
- OpenClaw CLI - - {installed ? `v${currentVersion}` : t('common.notInstalled')} - +
+
+
+

{t('settings.update')}

+
+ OpenClaw CLI + + {installed ? `v${currentVersion}` : t('common.notInstalled')} + +
{/* Check button (idle/error state) */} {(state === 'idle' || state === 'error') && updateTask.status === 'idle' && ( )} +
+ +
{/* Checking spinner */} {state === 'checking' && ( @@ -1360,9 +1701,9 @@ function UpdateSection({ {/* Update available */} {(state === 'available' || state === 'up-to-date') && versions && updateTask.status === 'idle' && ( -
+
{/* Channel selector */} -
+
{recentVersions.map((v) => (
diff --git a/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx b/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx index dc64746d..fb20819c 100644 --- a/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx +++ b/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx @@ -247,12 +247,12 @@ describe('CapabilitiesSection', () => { renderSection() - const checkbox = await screen.findByRole('switch', { name: '使用 npm 镜像' }) + const mirrorSwitch = await screen.findByRole('switch', { name: '使用 npm 镜像' }) await waitFor(() => { - expect(checkbox).toHaveAttribute('aria-checked', 'true') + expect(mirrorSwitch).toHaveAttribute('aria-checked', 'true') }) - fireEvent.click(checkbox) + fireEvent.click(mirrorSwitch) expect(screen.getByRole('button', { name: '安装' })).not.toBeDisabled() fireEvent.click(screen.getByRole('button', { name: '安装' })) @@ -295,12 +295,12 @@ describe('CapabilitiesSection', () => { renderSection() - const checkbox = await screen.findByRole('switch', { name: '使用 npm 镜像' }) + const mirrorSwitch = await screen.findByRole('switch', { name: '使用 npm 镜像' }) await waitFor(() => { - expect(checkbox).toHaveAttribute('aria-checked', 'true') + expect(mirrorSwitch).toHaveAttribute('aria-checked', 'true') }) - fireEvent.click(checkbox) + fireEvent.click(mirrorSwitch) const installBtn = screen.getByRole('button', { name: '安装' }) fireEvent.click(installBtn) expect(installBtn).toBeDisabled() diff --git a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx index 9d2e86f9..103d118b 100644 --- a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx +++ b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx @@ -26,6 +26,7 @@ vi.mock('react-i18next', () => ({ 'settings.checkUpdate': 'Check for updates', 'settings.checking': 'Checking...', 'settings.upToDate': 'Up to date', + 'settings.updateBackendUnavailable': 'Cannot reach the ClawMaster backend. Start the app with npm run dev:web or open the desktop app, then try again.', 'settings.updateChannel': 'Channel:', 'settings.targetVersion': 'Version:', 'settings.updateTo': `Update to ${opts?.version ?? ''}`, @@ -33,6 +34,16 @@ vi.mock('react-i18next', () => ({ 'settings.updateFailed': 'Update failed', 'settings.changelog': 'Release Notes', 'settings.currentLabel': 'current', + 'settings.clawmasterReleases': 'ClawMaster Releases', + 'settings.clawmasterReleasesDesc': 'Review app releases and open the right installer for this device.', + 'settings.clawmasterLatest': 'Latest release', + 'settings.clawmasterUpdateAvailable': `ClawMaster v${opts?.version ?? ''} is ready to install.`, + 'settings.clawmasterUpToDate': 'ClawMaster is up to date', + 'settings.clawmasterSourceGithub': 'Release details loaded from GitHub Releases.', + 'settings.clawmasterSourceNpm': 'Version detected from npm fallback; GitHub release details are unavailable.', + 'settings.clawmasterNpmFallbackDesc': 'GitHub release notes could not be loaded. Open the releases page to download the installer and review details.', + 'settings.clawmasterOpenInstaller': 'Open Installer', + 'settings.clawmasterOpenReleases': 'Open Releases', 'settings.acknowledgments': 'Acknowledgments', 'settings.profileTitle': 'OpenClaw profile', 'settings.profileDesc': 'Choose which OpenClaw runtime ClawMaster should read, configure, and launch.', @@ -183,6 +194,8 @@ const mockGetLocalDataStats = vi.fn() const mockRebuildLocalData = vi.fn() const mockResetLocalData = vi.fn() const mockIsTauri = vi.fn() +const mockCheckClawmasterRelease = vi.fn() +const mockSelectInstallerAsset = vi.fn() function deferred() { let resolve!: (value: T) => void @@ -262,6 +275,11 @@ vi.mock('@/shared/adapters/platform', () => ({ isTauri: (...args: any[]) => mockIsTauri(...args), })) +vi.mock('@/shared/adapters/clawmasterReleases', () => ({ + checkClawmasterReleaseResult: (...args: any[]) => mockCheckClawmasterRelease(...args), + selectInstallerAsset: (...args: any[]) => mockSelectInstallerAsset(...args), +})) + vi.mock('@/adapters', () => ({ platform: { detectSystem: vi.fn().mockResolvedValue(makeSystemInfo()), @@ -300,6 +318,29 @@ describe('UpdateSection', () => { vi.mocked(platform.detectSystem).mockResolvedValue(makeSystemInfo()) mockIsTauri.mockReturnValue(false) mockBootstrap.mockResolvedValue({ success: true }) + mockSelectInstallerAsset.mockReturnValue({ + name: 'ClawMaster_0.3.1_x64.msi', + url: 'https://example.com/clawmaster.msi', + }) + mockCheckClawmasterRelease.mockResolvedValue({ + success: true, + data: { + currentVersion: '0.3.0', + latestVersion: '0.3.1', + hasUpdate: true, + source: 'github', + latestRelease: { + version: '0.3.1', + tagName: 'v0.3.1', + name: 'v0.3.1', + body: '## Install\n\n### CLI + Web Console\n\n```bash\nnpm install -g clawmaster\nclawmaster\n```', + publishedAt: '2026-04-24T00:00:00.000Z', + htmlUrl: 'https://github.com/openmaster-ai/clawmaster/releases/tag/v0.3.1', + assets: [], + }, + releases: [], + }, + }) mockGetNpmProxy.mockResolvedValue({ success: true, data: { enabled: false, registryUrl: null }, @@ -393,6 +434,30 @@ describe('UpdateSection', () => { expect(screen.getAllByText(/v2026\.3\.28/).length).toBeGreaterThanOrEqual(1) }) + it('shows ClawMaster release details and opens the selected installer', async () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + renderSettings() + + expect(await screen.findByText('ClawMaster Releases')).toBeInTheDocument() + await waitFor(() => { + expect(mockCheckClawmasterRelease).toHaveBeenCalled() + }) + expect(await screen.findByText('Install')).toBeInTheDocument() + expect(screen.getByText('CLI + Web Console')).toBeInTheDocument() + expect(screen.getByText(/npm install -g clawmaster/)).toBeInTheDocument() + expect(screen.queryByText('## Install')).not.toBeInTheDocument() + expect(screen.queryByText('bash')).not.toBeInTheDocument() + + fireEvent.click(await screen.findByRole('button', { name: 'Open Installer' })) + + expect(openSpy).toHaveBeenCalledWith( + 'https://example.com/clawmaster.msi', + '_blank', + 'noopener,noreferrer', + ) + }) + it('auto-checks updates when opened from the update banner hash', async () => { mockListVersions.mockResolvedValue({ success: true, @@ -816,6 +881,23 @@ describe('UpdateSection', () => { }) }) + it('explains generic browser fetch failures during update checks', async () => { + mockListVersions.mockResolvedValue({ + success: false, + error: 'Failed to fetch', + }) + renderSettings() + await waitFor(() => screen.getByText('Check for updates')) + fireEvent.click(screen.getByText('Check for updates')) + await waitFor(() => { + expect( + screen.getByText( + 'Cannot reach the ClawMaster backend. Start the app with npm run dev:web or open the desktop app, then try again.' + ) + ).toBeInTheDocument() + }) + }) + it('calls reinstallOpenclawGlobal when update clicked', async () => { mockListVersions.mockResolvedValue({ success: true, @@ -879,6 +961,43 @@ describe('UpdateSection', () => { }) }) + it('renders release note markdown instead of raw markdown text', async () => { + mockListVersions.mockResolvedValue({ + success: true, + data: { + versions: ['2026.4.1', '2026.3.28'], + distTags: { latest: '2026.4.1' }, + }, + }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + tag_name: 'v2026.4.1', + name: 'v2026.4.1', + body: '## 2026.4.1\n\n### Highlights\n\n- Voice replies get `tts latest`\n- Plugin startup paths move faster', + published_at: '2026-04-27T00:00:00.000Z', + html_url: 'https://example.com/releases/2026.4.1', + }, + ]), + { status: 200 }, + ), + ) + + renderSettings() + await waitFor(() => screen.getByText('Check for updates')) + fireEvent.click(screen.getByText('Check for updates')) + const updateSection = document.querySelector('#settings-update') + expect(updateSection).not.toBeNull() + const update = within(updateSection as HTMLElement) + await waitFor(() => update.getByText('Release Notes')) + fireEvent.click(update.getByText('Release Notes')) + + expect(screen.getByRole('heading', { name: 'Highlights' })).toBeInTheDocument() + expect(screen.getByText('tts latest')).toBeInTheDocument() + expect(screen.queryByText('### Highlights')).not.toBeInTheDocument() + }) + it('saves a named OpenClaw profile from settings', async () => { renderSettings() diff --git a/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx b/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx index 11209595..a3f61f1b 100644 --- a/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx +++ b/packages/web/src/modules/setup/__tests__/SetupWizard.test.tsx @@ -351,11 +351,12 @@ describe('SetupWizard', () => { // ────────────────────────────────────────────────────── describe('Step 2: Provider selection', () => { - it('shows ERNIE under the invited sponsors tier', async () => { + it('shows Baidu sponsor providers under the invited sponsors tier', async () => { render( {}} />) await screen.findByText('Configure LLM Provider') expect(screen.getByText('Invited Sponsors')).toBeInTheDocument() + expect(screen.getByText('Baidu Qianfan Coding Plan')).toBeInTheDocument() expect(screen.getByText('ERNIE LLM API')).toBeInTheDocument() }) @@ -373,6 +374,7 @@ describe('SetupWizard', () => { await screen.findByText('Configure LLM Provider') // Tier 1 — invited sponsors + expect(screen.getByText('Baidu Qianfan Coding Plan')).toBeInTheDocument() expect(screen.getByText('ERNIE LLM API')).toBeInTheDocument() // Tier 2 featured (visible by default) expect(screen.getByText('OpenAI')).toBeInTheDocument() @@ -541,6 +543,83 @@ describe('SetupWizard', () => { }) }) + it('configures Z.AI GLM as a native provider in the wizard', async () => { + render( {}} />) + + await screen.findByText('Configure LLM Provider') + + fireEvent.click(screen.getByText('GLM (Z.AI)')) + expect(screen.getByRole('link', { name: 'Get GLM (Z.AI) API Key →' })).toHaveAttribute( + 'href', + 'https://z.ai/manage-apikey/apikey-list', + ) + fireEvent.change(screen.getByPlaceholderText(/Enter GLM \(Z\.AI\) API Key/i), { + target: { value: 'zai-key' }, + }) + fireEvent.click(screen.getByRole('button', { name: /Validate & Continue/i })) + + await waitFor(() => { + expect(mockSetupAdapter.onboarding.testApiKey).toHaveBeenCalledWith( + 'zai', + 'zai-key', + 'https://api.z.ai/api/paas/v4', + ) + }) + await waitFor(() => { + expect(mockSetupAdapter.onboarding.setApiKey).toHaveBeenCalledWith( + 'zai', + 'zai-key', + 'https://api.z.ai/api/paas/v4', + ) + }) + await waitFor(() => { + expect(mockGetProviderModelCatalogResult).toHaveBeenCalledWith({ + providerId: 'zai', + apiKey: 'zai-key', + baseUrl: 'https://api.z.ai/api/paas/v4', + }) + }) + }) + + it('configures Baidu Qianfan Coding Plan as an invited sponsor provider', async () => { + render( {}} />) + + await screen.findByText('Configure LLM Provider') + + fireEvent.click(screen.getByText('Baidu Qianfan Coding Plan')) + expect(screen.getByRole('link', { name: 'Get Baidu Qianfan Coding Plan BCE API Key →' })).toHaveAttribute( + 'href', + 'https://cloud.baidu.com/doc/qianfan/s/Rmn2ms2nm', + ) + expect(screen.getByText("Uses Baidu BCE Qianfan Coding Plan's OpenAI-compatible coding endpoint.")).toBeInTheDocument() + fireEvent.change(screen.getByPlaceholderText(/Enter Baidu Qianfan Coding Plan BCE API Key/i), { + target: { value: 'bce-test-key' }, + }) + fireEvent.click(screen.getByRole('button', { name: /Validate & Continue/i })) + + await waitFor(() => { + expect(mockSetupAdapter.onboarding.testApiKey).toHaveBeenCalledWith( + 'baiduqianfancodingplan', + 'bce-test-key', + 'https://qianfan.baidubce.com/v2/coding', + ) + }) + await waitFor(() => { + expect(mockSetupAdapter.onboarding.setApiKey).toHaveBeenCalledWith( + 'baiduqianfancodingplan', + 'bce-test-key', + 'https://qianfan.baidubce.com/v2/coding', + ) + }) + await waitFor(() => { + expect(mockGetProviderModelCatalogResult).toHaveBeenCalledWith({ + providerId: 'baiduqianfancodingplan', + apiKey: 'bce-test-key', + baseUrl: 'https://qianfan.baidubce.com/v2/coding', + }) + }) + }) + it('requires revalidation when the API key changes after validation', async () => { render( {}} />) diff --git a/packages/web/src/modules/setup/__tests__/realAdapter.test.ts b/packages/web/src/modules/setup/__tests__/realAdapter.test.ts index adf402b1..f9d757dd 100644 --- a/packages/web/src/modules/setup/__tests__/realAdapter.test.ts +++ b/packages/web/src/modules/setup/__tests__/realAdapter.test.ts @@ -242,6 +242,39 @@ describe('realSetupAdapter', () => { }) }) + it('writes Z.AI GLM as a native OpenAI-compatible provider', async () => { + vi.mocked(setConfigResult).mockResolvedValue({ + success: true, + data: undefined, + error: null, + }) + + await expect( + realSetupAdapter.onboarding.setApiKey('zai', 'zai-key'), + ).resolves.toBeUndefined() + + expect(setConfigResult).toHaveBeenCalledWith('models.providers.zai', { + apiKey: 'zai-key', + api: 'openai-completions', + baseUrl: 'https://api.z.ai/api/paas/v4', + models: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-5', name: 'GLM-5' }, + { id: 'glm-5-turbo', name: 'GLM-5 Turbo' }, + { id: 'glm-5v-turbo', name: 'GLM-5V Turbo' }, + { id: 'glm-4.7', name: 'GLM-4.7' }, + { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash' }, + { id: 'glm-4.7-flashx', name: 'GLM-4.7 FlashX' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + { id: 'glm-4.6v', name: 'GLM-4.6V' }, + { id: 'glm-4.5', name: 'GLM-4.5' }, + { id: 'glm-4.5-air', name: 'GLM-4.5 Air' }, + { id: 'glm-4.5-flash', name: 'GLM-4.5 Flash' }, + { id: 'glm-4.5v', name: 'GLM-4.5V' }, + ], + }) + }) + it('writes the ERNIE provider as a custom openai-compatible provider', async () => { vi.mocked(setConfigResult).mockResolvedValue({ success: true, @@ -279,6 +312,29 @@ describe('realSetupAdapter', () => { ) }) + it('writes Baidu Qianfan Coding Plan as a BCE OpenAI-compatible provider', async () => { + vi.mocked(setConfigResult).mockResolvedValue({ + success: true, + data: undefined, + error: null, + }) + + await expect( + realSetupAdapter.onboarding.setApiKey('baiduqianfancodingplan', 'bce-test-key'), + ).resolves.toBeUndefined() + + expect(setConfigResult).toHaveBeenCalledWith('models.providers.baiduqianfancodingplan', { + apiKey: 'bce-test-key', + api: 'openai-completions', + baseUrl: 'https://qianfan.baidubce.com/v2/coding', + models: [ + { id: 'qianfan-code-latest', name: 'Qianfan Code Latest' }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B A35B Instruct' }, + { id: 'qwen3-coder-30b-a3b-instruct', name: 'Qwen3 Coder 30B A3B Instruct' }, + ], + }) + }) + it('throws when the ERNIE-Image runtime plugin root cannot be resolved', async () => { vi.mocked(setConfigResult).mockResolvedValue({ success: true, @@ -742,6 +798,33 @@ describe('realSetupAdapter', () => { }) }) + it('probes Baidu Qianfan Coding Plan through the BCE chat completions endpoint', async () => { + vi.mocked(probeHttpStatusResult).mockResolvedValue({ + success: true, + data: { ok: true, status: 200 }, + error: null, + }) + + await expect( + realSetupAdapter.onboarding.testApiKey('baiduqianfancodingplan', 'bce-test-key'), + ).resolves.toBe(true) + + expect(probeHttpStatusResult).toHaveBeenCalledWith({ + url: 'https://qianfan.baidubce.com/v2/coding/chat/completions', + method: 'POST', + headers: { + Authorization: 'Bearer bce-test-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'qianfan-code-latest', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 1, + }), + timeoutMs: 10000, + }) + }) + it('probes the ERNIE-Image provider through the images generations endpoint', async () => { vi.mocked(probeHttpStatusResult).mockResolvedValue({ success: true, diff --git a/packages/web/src/modules/setup/types.ts b/packages/web/src/modules/setup/types.ts index b4b0baae..caae1345 100644 --- a/packages/web/src/modules/setup/types.ts +++ b/packages/web/src/modules/setup/types.ts @@ -95,7 +95,7 @@ export interface OnboardingState { } export const DEFAULT_ONBOARDING_STATE: OnboardingState = { - provider: 'baidu-aistudio', + provider: 'baiduqianfancodingplan', apiKey: '', customBaseUrl: '', model: '', @@ -228,6 +228,33 @@ export const PROVIDERS: Record = { ], defaultModel: 'deepseek-chat', }, + zai: { + label: 'GLM (Z.AI)', + labelByLocale: { + zh: '智谱 GLM', + en: 'GLM (Z.AI)', + ja: 'GLM (Z.AI)', + }, + api: 'openai-completions', + keyUrl: 'https://z.ai/manage-apikey/apikey-list', + baseUrl: 'https://api.z.ai/api/paas/v4', + models: [ + { id: 'glm-5.1', name: 'GLM-5.1' }, + { id: 'glm-5', name: 'GLM-5' }, + { id: 'glm-5-turbo', name: 'GLM-5 Turbo' }, + { id: 'glm-5v-turbo', name: 'GLM-5V Turbo' }, + { id: 'glm-4.7', name: 'GLM-4.7' }, + { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash' }, + { id: 'glm-4.7-flashx', name: 'GLM-4.7 FlashX' }, + { id: 'glm-4.6', name: 'GLM-4.6' }, + { id: 'glm-4.6v', name: 'GLM-4.6V' }, + { id: 'glm-4.5', name: 'GLM-4.5' }, + { id: 'glm-4.5-air', name: 'GLM-4.5 Air' }, + { id: 'glm-4.5-flash', name: 'GLM-4.5 Flash' }, + { id: 'glm-4.5v', name: 'GLM-4.5V' }, + ], + defaultModel: 'glm-5.1', + }, minimax: { label: 'MiniMax', keyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', @@ -308,6 +335,30 @@ export const PROVIDERS: Record = { ], defaultModel: 'ernie-5.0-thinking-preview', }, + baiduqianfancodingplan: { + label: 'Baidu Qianfan Coding Plan', + labelByLocale: { + zh: '百度千帆 Coding Plan', + en: 'Baidu Qianfan Coding Plan', + ja: 'Baidu Qianfan Coding Plan', + }, + api: 'openai-completions', + keyUrl: 'https://cloud.baidu.com/doc/qianfan/s/Rmn2ms2nm', + credentialLabel: 'BCE API Key', + credentialLabelByLocale: { + zh: 'BCE API Key', + en: 'BCE API Key', + ja: 'BCE APIキー', + }, + noteKey: 'providers.baiduQianfanCodingPlanNote', + baseUrl: 'https://qianfan.baidubce.com/v2/coding', + models: [ + { id: 'qianfan-code-latest', name: 'Qianfan Code Latest' }, + { id: 'qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B A35B Instruct' }, + { id: 'qwen3-coder-30b-a3b-instruct', name: 'Qwen3 Coder 30B A3B Instruct' }, + ], + defaultModel: 'qianfan-code-latest', + }, 'baidu-aistudio-image': { label: 'ERNIE-Image', labelByLocale: { @@ -494,7 +545,7 @@ export const TEXT_PROVIDER_TIERS: readonly ProviderTier[] = [ { id: 'sponsors', labelKey: 'providers.tierInvitedSponsors', - members: ['baidu-aistudio'], + members: ['baiduqianfancodingplan', 'baidu-aistudio'], }, { id: 'featured', @@ -504,6 +555,7 @@ export const TEXT_PROVIDER_TIERS: readonly ProviderTier[] = [ 'anthropic', 'google', 'deepseek', + 'zai', 'kimi-coding', 'minimax', 'siliconflow', @@ -542,6 +594,7 @@ export const PRIMARY_PROVIDERS = TEXT_PROVIDER_TIERS.flatMap((tier) => [ export const PRIMARY_IMAGE_PROVIDERS = ['baidu-aistudio-image', 'google-image', 'openai-image'] as const export const PROVIDER_BADGES: Partial> = { + baiduqianfancodingplan: 'golden-sponsor', 'baidu-aistudio': 'golden-sponsor', 'baidu-aistudio-image': 'golden-sponsor', } diff --git a/packages/web/src/modules/skills/__tests__/catalog.test.ts b/packages/web/src/modules/skills/__tests__/catalog.test.ts index 95c7d255..25e24f2d 100644 --- a/packages/web/src/modules/skills/__tests__/catalog.test.ts +++ b/packages/web/src/modules/skills/__tests__/catalog.test.ts @@ -74,6 +74,12 @@ describe('Skills catalog', () => { category: 'productivity', installSource: 'bundled', }) + expect(SKILL_CATALOG.find((skill) => skill.slug === 'package-download-tracker')).toMatchObject({ + slug: 'package-download-tracker', + name: 'Package Download Tracker', + category: 'productivity', + installSource: 'bundled', + }) }) it('CATEGORY_ORDER covers all used categories', () => { diff --git a/packages/web/src/modules/skills/catalog.ts b/packages/web/src/modules/skills/catalog.ts index 2b0fe6da..cebdbd47 100644 --- a/packages/web/src/modules/skills/catalog.ts +++ b/packages/web/src/modules/skills/catalog.ts @@ -134,6 +134,15 @@ export const SKILL_CATALOG: CatalogSkill[] = [ installSource: 'bundled', sourceUrl: 'https://github.com/openmaster-ai/clawmaster', }, + { + slug: 'package-download-tracker', + name: 'Package Download Tracker', + descriptionKey: 'skills.catalog.packageDownloadTracker.desc', + category: 'productivity', + skillKey: 'package-download-tracker', + installSource: 'bundled', + sourceUrl: 'https://github.com/openmaster-ai/clawmaster/issues/111', + }, { slug: 'image-generate', name: 'Image Generate', diff --git a/packages/web/src/modules/wiki/WikiPage.tsx b/packages/web/src/modules/wiki/WikiPage.tsx new file mode 100644 index 00000000..71f82622 --- /dev/null +++ b/packages/web/src/modules/wiki/WikiPage.tsx @@ -0,0 +1,871 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + AlertTriangle, + BookOpen, + CheckCircle2, + CircleHelp, + FileText, + GitBranch, + History, + Link2, + LoaderCircle, + RefreshCw, + Search, + ShieldCheck, + Sparkles, + UploadCloud, + X, +} from 'lucide-react' +import type { + WikiEvolvePayload, + WikiIngestPayload, + WikiLintPayload, + WikiPageDetail, + WikiPageSummary, + WikiQueryPayload, + WikiSearchResult, + WikiStatusPayload, + WikiSynthesizePayload, +} from '@/lib/types' +import { + wikiDeepEvolveResult, + wikiEvolveResult, + wikiIngestResult, + wikiLintResult, + wikiPageResult, + wikiPagesResult, + wikiQueryResult, + wikiSearchResult, + wikiStatusResult, + wikiSynthesizeResult, +} from '@/shared/adapters/wiki' + +function statusClass(status: string): string { + if (status === 'fresh') return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + if (status === 'aging') return 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + return 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300' +} + +function lifecycleClass(state: string): string { + if (state === 'just_ingested') return 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300' + if (state === 'evolved') return 'border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300' + if (state === 'outdated') return 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300' + return 'border-border bg-muted/30 text-muted-foreground' +} + +function hasEvolveChange(page: WikiPageSummary): boolean { + return page.lifecycleState === 'evolved' && Boolean(page.evolveChangedAt || page.evolveChangeSummary) +} + +function shortDate(value: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +function evolveWarningText(warning: string, evolve: WikiEvolvePayload, t: (key: string, values?: Record) => string): string { + if (warning === 'wiki_conflicts_detected') { + return t('wiki.warning.conflictsDetected', { count: evolve.conflictCount }) + } + if (warning === 'auto_evolve_failed') { + return t('wiki.warning.autoEvolveFailed') + } + return warning +} + +function PageSignalChips({ page, compact = false }: { page: WikiPageSummary; compact?: boolean }) { + const { t } = useTranslation() + return ( +
+ + {t(`wiki.freshness.${page.freshnessStatus}`)} + + + {t(`wiki.lifecycle.${page.lifecycleState}`)} + + {!compact && hasEvolveChange(page) && page.evolvedAt ? ( + + {t('wiki.signal.changedAt', { time: shortDate(page.evolvedAt) })} + + ) : null} +
+ ) +} + +function pageOriginKey(page: WikiPageSummary): string { + if (page.type === 'synthesis') return 'wiki.origin.llm' + if (page.type === 'source') return 'wiki.origin.source' + return 'wiki.origin.maintained' +} + +function PageOriginBanner({ page }: { page: WikiPageDetail }) { + const { t } = useTranslation() + const isSynthesis = page.type === 'synthesis' + const isSource = page.type === 'source' + return ( +
+

{t(pageOriginKey(page))}

+

+ {isSynthesis + ? t('wiki.origin.llmDetail', { count: page.sourceCount }) + : isSource + ? t('wiki.origin.sourceDetail') + : t('wiki.origin.maintainedDetail')} +

+
+ ) +} + +function PageEvolutionBanner({ page }: { page: WikiPageDetail }) { + const { t } = useTranslation() + const changedByEvolve = hasEvolveChange(page) + return ( +
+

+ {changedByEvolve ? t('wiki.evolution.changedTitle') : t('wiki.evolution.checkedTitle')} +

+

+ {changedByEvolve + ? t('wiki.evolution.changedDetail', { time: shortDate(page.evolveChangedAt || page.evolvedAt || page.updatedAt) }) + : t('wiki.evolution.checkedDetail', { time: shortDate(page.evolveCheckedAt || page.updatedAt) })} +

+

+ {changedByEvolve && page.evolveChangeSummary ? page.evolveChangeSummary : t('wiki.signal.checkedOnly')} +

+ {page.evolveSource ? ( +

{t('wiki.signal.evidenceSource', { source: page.evolveSource })}

+ ) : null} +
+ ) +} + +function isLikelyUrl(value: string): boolean { + return /^https?:\/\//i.test(value.trim()) +} + +function MarkdownPreview({ + content, + onOpenLink, +}: { + content: string + onOpenLink: (target: string) => void +}) { + const lines = content.split(/\r?\n/) + return ( +
+ {lines.map((line, index) => { + const heading = line.match(/^(#{1,3})\s+(.+)$/) + if (heading) { + const size = heading[1].length === 1 ? 'text-xl' : heading[1].length === 2 ? 'text-lg' : 'text-base' + return

{heading[2]}

+ } + if (!line.trim()) return
+ const parts: Array<{ text: string; link?: string }> = [] + const pattern = /\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g + let cursor = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(line))) { + if (match.index > cursor) parts.push({ text: line.slice(cursor, match.index) }) + const link = match[1]?.trim() || '' + parts.push({ text: link, link }) + cursor = match.index + match[0].length + } + if (cursor < line.length) parts.push({ text: line.slice(cursor) }) + return ( +

+ {parts.map((part, partIndex) => part.link ? ( + + ) : ( + {part.text} + ))} +

+ ) + })} +
+ ) +} + +function PageList({ + pages, + selectedId, + onSelect, +}: { + pages: Array + selectedId: string | null + onSelect: (pageId: string) => void +}) { + const { t } = useTranslation() + if (pages.length === 0) { + return

{t('wiki.emptyPages')}

+ } + return ( +
    + {pages.map((page) => ( +
  • + +
  • + ))} +
+ ) +} + +function PageDetailModal({ + page, + onClose, + onOpenLink, +}: { + page: WikiPageDetail | null + onClose: () => void + onOpenLink: (target: string) => void +}) { + const { t } = useTranslation() + if (!page) return null + const changedByEvolve = hasEvolveChange(page) + + return ( +
+ + ) +} + +export default function WikiPage() { + const { t } = useTranslation() + const [status, setStatus] = useState(null) + const [pages, setPages] = useState([]) + const [selectedPage, setSelectedPage] = useState(null) + const [searchText, setSearchText] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [queryText, setQueryText] = useState('') + const [queryResult, setQueryResult] = useState(null) + const [synthesisResult, setSynthesisResult] = useState(null) + const [ingestTitle, setIngestTitle] = useState('') + const [ingestSource, setIngestSource] = useState('') + const [ingestContent, setIngestContent] = useState('') + const [pendingUrlInput, setPendingUrlInput] = useState(null) + const [linkChoiceNotice, setLinkChoiceNotice] = useState(null) + const [searchSubmitted, setSearchSubmitted] = useState(false) + const [lintResult, setLintResult] = useState(null) + const [evolveResult, setEvolveResult] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(null) + const [error, setError] = useState(null) + + const visiblePages = searchSubmitted ? searchResults : pages + const bonusStats = useMemo(() => { + const generated = pages.filter((page) => page.type === 'synthesis').length + const sources = pages.filter((page) => page.type === 'source').length + const checked = pages.filter((page) => Boolean(page.evolveCheckedAt)).length + const maintenanceChanged = pages.filter((page) => Boolean(page.evolveChangedAt || page.evolveChangeSummary)).length + const evolved = pages.filter(hasEvolveChange).length + return { generated, sources, checked, maintenanceChanged, evolved } + }, [pages]) + + const loadWiki = useCallback(async () => { + setLoading(true) + setError(null) + const [statusRes, pagesRes] = await Promise.all([wikiStatusResult(), wikiPagesResult()]) + if (!statusRes.success || !statusRes.data) setError(statusRes.error || t('wiki.loadFailed')) + else setStatus(statusRes.data) + if (!pagesRes.success) setError(pagesRes.error || t('wiki.loadFailed')) + else setPages(pagesRes.data ?? []) + setLoading(false) + }, [t]) + + const openPage = useCallback(async (pageId: string) => { + setActionLoading(`page:${pageId}`) + setError(null) + const result = await wikiPageResult(pageId) + if (result.success && result.data) { + setSelectedPage(result.data) + setDetailOpen(true) + } else { + const matched = pages.find((page) => page.title === pageId || page.id === pageId) + if (matched) { + const retry = await wikiPageResult(matched.id) + if (retry.success && retry.data) { + setSelectedPage(retry.data) + setDetailOpen(true) + } + else setError(retry.error || t('wiki.loadFailed')) + } else { + setError(result.error || t('wiki.loadFailed')) + } + } + setActionLoading(null) + }, [pages, t]) + + useEffect(() => { + void loadWiki() + }, [loadWiki]) + + async function handleSearch() { + const query = searchText.trim() + setActionLoading('search') + setError(null) + if (!query) { + setSearchResults([]) + setSearchSubmitted(false) + setActionLoading(null) + return + } + const result = await wikiSearchResult(query, { limit: 20 }) + if (result.success) { + setSearchSubmitted(true) + setSearchResults(result.data ?? []) + } + else setError(result.error || t('wiki.searchFailed')) + setActionLoading(null) + } + + async function submitIngest(confirmUrlIngest = false) { + setActionLoading('ingest') + setError(null) + setPendingUrlInput(null) + setLinkChoiceNotice(null) + const source = ingestSource.trim() + const result = await wikiIngestResult({ + title: ingestTitle.trim() || undefined, + content: ingestContent.trim() || (isLikelyUrl(source) ? undefined : source), + sourceUrl: isLikelyUrl(source) ? source : undefined, + sourcePath: source && !isLikelyUrl(source) ? source : undefined, + sourceType: isLikelyUrl(source) ? 'url' : source ? 'file' : 'manual', + pageType: 'source', + confirmUrlIngest, + }) + if (result.success && result.data) { + if (result.data.confirmationRequired) { + setPendingUrlInput(result.data) + } else { + if (result.data.evolve) setEvolveResult(result.data.evolve) + await loadWiki() + if (result.data.page) await openPage(result.data.page.id) + } + } else { + setError(result.error || t('wiki.ingestFailed')) + } + setActionLoading(null) + } + + async function handleQuery() { + const query = queryText.trim() + if (!query) return + setActionLoading('query') + setError(null) + const result = await wikiQueryResult(query, { limit: 6 }) + if (result.success && result.data) { + setQueryResult(result.data) + setSynthesisResult(null) + } + else setError(result.error || t('wiki.queryFailed')) + setActionLoading(null) + } + + async function handleSynthesize() { + const query = (queryResult?.query || queryText).trim() + if (!query) return + setActionLoading('synthesize') + setError(null) + const result = await wikiSynthesizeResult({ query, limit: 5 }) + if (result.success && result.data) { + setSynthesisResult(result.data) + if (result.data.evolve) setEvolveResult(result.data.evolve) + await loadWiki() + await openPage(result.data.page.id) + } else { + setError(result.error || t('wiki.synthesizeFailed')) + } + setActionLoading(null) + } + + async function handleLint() { + setActionLoading('lint') + setError(null) + const result = await wikiLintResult() + if (result.success && result.data) { + setLintResult(result.data) + await loadWiki() + } else { + setError(result.error || t('wiki.lintFailed')) + } + setActionLoading(null) + } + + async function handleEvolve() { + setActionLoading('evolve') + setError(null) + const result = await wikiEvolveResult() + if (result.success && result.data) { + setEvolveResult(result.data) + await loadWiki() + } else { + setError(result.error || t('wiki.evolveFailed')) + } + setActionLoading(null) + } + + async function handleDeepEvolve() { + setActionLoading('deep-evolve') + setError(null) + const result = await wikiDeepEvolveResult() + if (result.success && result.data) { + setEvolveResult(result.data) + await loadWiki() + } else { + setError(result.error || t('wiki.deepEvolveFailed')) + } + setActionLoading(null) + } + + const stats = useMemo(() => [ + { label: t('wiki.statPages'), value: String(status?.pageCount ?? 0), icon: FileText }, + { label: t('wiki.statSources'), value: String(status?.sourceCount ?? 0), icon: Link2 }, + { label: t('wiki.statStale'), value: String(status?.staleCount ?? 0), icon: AlertTriangle, hint: t('wiki.statStaleHint') }, + { label: t('wiki.statEngine'), value: status?.memory.engine ?? '-', icon: ShieldCheck }, + ], [status, t]) + + return ( +
+
+
+

{t('wiki.kicker')}

+

{t('wiki.title')}

+

{t('wiki.subtitle')}

+
+ +
+ + {error ?

{error}

: null} + +
+ {stats.map((item) => { + const Icon = item.icon + return ( +
+ +
+

+ {item.label} + {'hint' in item && item.hint ? ( + + + + {item.hint} + + + ) : null} +

+

{item.value}

+
+
+ ) + })} +
+ +
+
+
+ +

{t('wiki.bonusTitle')}

+
+

{t('wiki.bonusSubtitle')}

+
+
+
+

{bonusStats.generated}

+

{t('wiki.bonusGenerated')}

+

{t('wiki.bonusGeneratedHint')}

+
+
+

{bonusStats.sources}

+

{t('wiki.bonusSources')}

+

{t('wiki.bonusSourcesHint')}

+
+
+

{bonusStats.checked}

+

{t('wiki.bonusChecked')}

+

+ {t('wiki.bonusCheckedHint', { + changed: bonusStats.maintenanceChanged, + evolved: bonusStats.evolved, + })} +

+
+
+

{status?.conflictCount ?? 0}

+

{t('wiki.bonusIssues')}

+

{t('wiki.bonusIssuesHint')}

+
+
+
+ +
+
+
+ +

{t('wiki.ingestTitle')}

+
+ setIngestTitle(event.target.value)} + placeholder={t('wiki.ingestTitlePlaceholder')} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" + /> + setIngestSource(event.target.value)} + placeholder={t('wiki.ingestSourcePlaceholder')} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" + /> +