From 06bc9e11ad31fb1edbea259acd5c4faa0322aed1 Mon Sep 17 00:00:00 2001
From: MemScribe Maintainers
Date: Tue, 28 Jul 2026 16:03:15 +0800
Subject: [PATCH 1/6] refactor: unify host-native memory agents
Signed-off-by: MemScribe Maintainers
---
CHANGELOG.md | 31 +-
CONTRIBUTING.md | 14 +-
README.md | 2 +-
README.zh.md | 2 +-
SUPPORT.md | 14 +-
docs/integrations.md | 70 +-
docs/release.md | 12 +-
examples/README.md | 83 +-
examples/dream-regression.mjs | 231 ----
examples/extraction-regression.mjs | 259 -----
examples/hermes/README.md | 33 +-
examples/hermes/e2e-strict.mjs | 343 ------
examples/hermes/plugin-register.mjs | 4 +-
examples/hermes/run.mjs | 88 --
examples/learning-loop/README.md | 28 -
examples/learning-loop/run.mjs | 325 ------
examples/openclaw/README.md | 41 +-
examples/package.json | 3 +-
examples/pi/README.md | 42 +-
examples/pi/e2e-boundaries.mjs | 583 ----------
examples/pi/e2e-deepseek.mjs | 862 ---------------
examples/pi/e2e-recall-drill.mjs | 364 -------
examples/pi/e2e-strict.mjs | 634 -----------
examples/pi/run.mjs | 97 --
examples/shared/fake-model.mjs | 82 --
examples/shared/transcript.mjs | 18 -
package.json | 7 +-
packages/adapters/NOTICE | 4 +
packages/adapters/README.md | 72 +-
packages/adapters/THIRD_PARTY_LICENSES | 31 +-
.../adapters/openclaw-extension/index.mjs | 18 +-
packages/adapters/openclaw.plugin.json | 4 +-
packages/adapters/package.json | 20 +-
packages/adapters/pi-extension/index.mjs | 4 +-
packages/adapters/src/harness-port.ts | 27 +-
packages/adapters/src/hermes.ts | 6 +-
.../adapters/src/host-memflywheel.test.ts | 71 +-
packages/adapters/src/host-memflywheel.ts | 72 +-
packages/adapters/src/index.ts | 43 +-
.../adapters/src/model-test-support.test.ts | 129 +++
.../adapters/src/openai-env-model.test.ts | 35 -
packages/adapters/src/openai-env-model.ts | 90 --
.../adapters/src/openclaw-native-model.ts | 87 ++
packages/adapters/src/openclaw-port.test.ts | 182 +++-
packages/adapters/src/openclaw-port.ts | 161 ++-
packages/adapters/src/opencode-pi-model.ts | 90 ++
packages/adapters/src/opencode-port.test.ts | 275 +++--
packages/adapters/src/opencode-port.ts | 352 ++++++-
packages/adapters/src/opencode.ts | 2 +-
packages/adapters/src/pi-port.test.ts | 327 ++----
packages/adapters/src/pi-port.ts | 232 +---
packages/adapters/tsconfig.json | 2 +-
packages/core/NOTICE | 5 +
packages/core/THIRD_PARTY_LICENSES | 37 +-
packages/core/package.json | 6 +-
packages/core/src/dream.test.ts | 30 +-
packages/core/src/dream.ts | 7 +-
packages/core/src/extract.test.ts | 71 +-
packages/core/src/extract.ts | 84 +-
packages/core/src/index.ts | 9 +-
packages/core/src/lock.test.ts | 70 +-
packages/core/src/lock.ts | 110 +-
packages/core/src/recall.test.ts | 2 +
packages/core/src/recall.ts | 2 +
packages/{model => embeddings}/LICENSE | 0
packages/{model => embeddings}/NOTICE | 0
packages/embeddings/README.md | 15 +
.../THIRD_PARTY_LICENSES | 0
packages/{model => embeddings}/package.json | 12 +-
packages/embeddings/src/index.test.ts | 42 +
packages/embeddings/src/index.ts | 100 ++
packages/{model => embeddings}/tsconfig.json | 0
packages/hermes-plugin/README.md | 14 +-
packages/hermes-plugin/bin/install.mjs | 19 +
packages/hermes-plugin/bridge/worker.mjs | 34 +-
packages/hermes-plugin/guard/__init__.py | 73 ++
packages/hermes-plugin/guard/plugin.yaml | 6 +
packages/hermes-plugin/package.json | 7 +-
packages/hermes-plugin/provider/__init__.py | 84 +-
.../tests/provider_contract_test.py | 71 +-
packages/model/README.md | 84 --
packages/model/src/index.test.ts | 260 -----
packages/model/src/index.ts | 347 ------
packages/sdk/README.md | 266 +----
packages/sdk/package.json | 6 +-
packages/sdk/src/agent-runner.test.ts | 27 +
packages/sdk/src/agent-runner.ts | 144 +++
packages/sdk/src/agent-test-support.test.ts | 100 ++
packages/sdk/src/assembly.test.ts | 407 ++-----
packages/sdk/src/dream-agent.ts | 35 +-
packages/sdk/src/extraction-agent.ts | 42 +-
packages/sdk/src/index.ts | 47 +-
.../sdk/src/skill-evolution-agent.test.ts | 87 +-
packages/sdk/src/skill-evolution-agent.ts | 137 +--
packages/sdk/src/tool-agent.ts | 197 ----
packages/sdk/tsconfig.json | 2 +-
packages/skills/package.json | 2 +-
pnpm-lock.yaml | 992 +++++++++++++++++-
pnpm-workspace.yaml | 2 +
99 files changed, 3719 insertions(+), 6983 deletions(-)
delete mode 100644 examples/dream-regression.mjs
delete mode 100644 examples/extraction-regression.mjs
delete mode 100644 examples/hermes/e2e-strict.mjs
delete mode 100644 examples/hermes/run.mjs
delete mode 100644 examples/learning-loop/README.md
delete mode 100644 examples/learning-loop/run.mjs
delete mode 100644 examples/pi/e2e-boundaries.mjs
delete mode 100644 examples/pi/e2e-deepseek.mjs
delete mode 100644 examples/pi/e2e-recall-drill.mjs
delete mode 100644 examples/pi/e2e-strict.mjs
delete mode 100644 examples/pi/run.mjs
delete mode 100644 examples/shared/fake-model.mjs
delete mode 100644 examples/shared/transcript.mjs
create mode 100644 packages/adapters/src/model-test-support.test.ts
delete mode 100644 packages/adapters/src/openai-env-model.test.ts
delete mode 100644 packages/adapters/src/openai-env-model.ts
create mode 100644 packages/adapters/src/openclaw-native-model.ts
create mode 100644 packages/adapters/src/opencode-pi-model.ts
rename packages/{model => embeddings}/LICENSE (100%)
rename packages/{model => embeddings}/NOTICE (100%)
create mode 100644 packages/embeddings/README.md
rename packages/{model => embeddings}/THIRD_PARTY_LICENSES (100%)
rename packages/{model => embeddings}/package.json (83%)
create mode 100644 packages/embeddings/src/index.test.ts
create mode 100644 packages/embeddings/src/index.ts
rename packages/{model => embeddings}/tsconfig.json (100%)
create mode 100644 packages/hermes-plugin/guard/__init__.py
create mode 100644 packages/hermes-plugin/guard/plugin.yaml
delete mode 100644 packages/model/README.md
delete mode 100644 packages/model/src/index.test.ts
delete mode 100644 packages/model/src/index.ts
create mode 100644 packages/sdk/src/agent-runner.test.ts
create mode 100644 packages/sdk/src/agent-runner.ts
create mode 100644 packages/sdk/src/agent-test-support.test.ts
delete mode 100644 packages/sdk/src/tool-agent.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6870fc..2541df8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,17 +7,36 @@ for published packages.
## [Unreleased]
+## [0.1.1] - 2026-07-28
+
### Added
-- Kubernetes-level E2E CI workflow using kind and agent-sandbox CRDs. Deploys
- Pi and Hermes agents in separate namespaces with memflywheel packages baked
- into custom Docker images, then validates the full memory lifecycle against
- a mock LLM (offline, no API key required).
- Optional embedding pre-recall for large `MEMORY.md` indexes, configured through
OpenAI-compatible embedding endpoint, API key, model, batch size, and retrieval
limit environment variables.
-- Documentation for the 200-line direct index limit and the optional
- endpoint/API-key setup needed to enable pre-recall.
+- Hermes host-write guard and learned-skill synchronization for native host
+ integration.
+
+### Changed
+
+- Unified extraction, dream, and skill evolution on Pi Agent Core with
+ host-resolved `pi-ai` model bindings.
+- Reused each host's active model, endpoint, credentials, headers, protocol, and
+ provider options across Pi, OpenCode, Hermes, and OpenClaw.
+- Replaced the internal provider-neutral model package with the optional
+ embeddings package and provider-native transports.
+- Moved cross-process memory locking to `proper-lockfile` and raised the Node.js
+ requirement to 22.19.
+
+### Fixed
+
+- Preserved provider-native assistant replay fields across multi-step tool loops.
+- Made OpenCode turn completion and idle delivery serial and idempotent, while
+ supporting OpenAI-compatible, Responses, Anthropic, Google, Bedrock, and
+ Mistral transports without silent protocol fallback.
+- Reused OpenCode and Pi host credentials on every background model turn.
+- Declared the OpenClaw plugin API as a compatible semver floor so current
+ OpenClaw releases can install the package.
### Security
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d5d2618..854ab41 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,13 +21,13 @@ pnpm run ci
Please keep changes inside the public MemFlywheel scope:
-| Area | Rule |
-| --------- | -------------------------------------------------------------------------------------------------------------------- |
-| Storage | Markdown files plus YAML frontmatter are the source of truth. |
-| Index | `MEMORY.md` is a rebuildable index. Do not hand-edit it. |
-| Recall | Full-index recall only. Do not add embeddings, BM25, top-k, or vector search. |
-| LLM calls | `@memflywheel/core` must not call LLMs directly. Use injected runners or `@memflywheel/model` canonical model ports. |
-| Naming | Use `MemFlywheel`, `memflywheel`, `@memflywheel/*`, and `MEMFLYWHEEL_*`. |
+| Area | Rule |
+| --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| Storage | Markdown files plus YAML frontmatter are the source of truth. |
+| Index | `MEMORY.md` is a rebuildable index. Do not hand-edit it. |
+| Recall | Full-index recall only. Do not add embeddings, BM25, top-k, or vector search. |
+| LLM calls | `@memflywheel/core` must not call LLMs directly. Write-side tasks use the SDK's Pi Agent Core runner and host-resolved pi-ai bindings. |
+| Naming | Use `MemFlywheel`, `memflywheel`, `@memflywheel/*`, and `MEMFLYWHEEL_*`. |
## Pull Requests
diff --git a/README.md b/README.md
index f57959c..b5a6591 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-
+
diff --git a/README.zh.md b/README.zh.md
index 76d9e48..925fc43 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -18,7 +18,7 @@
-
+
diff --git a/SUPPORT.md b/SUPPORT.md
index 1c76ae2..8363555 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -6,13 +6,13 @@ https://github.com/iflytek/memflywheel/issues
Before filing an issue, please include:
-| Field | Example |
-| --------------- | ---------------------------------------------------------------------------------------------------------------------- |
-| Package | `@memflywheel/core`, `@memflywheel/model`, `@memflywheel/sdk`, `@memflywheel/skills`, or `@iflytekopensource/adapters` |
-| Runtime | Node.js version and operating system |
-| Command | The exact command or API call that failed |
-| Expected result | What you expected to happen |
-| Actual result | What happened instead |
+| Field | Example |
+| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| Package | `@memflywheel/core`, `@memflywheel/embeddings`, `@memflywheel/sdk`, `@memflywheel/skills`, or `@iflytekopensource/adapters` |
+| Runtime | Node.js version and operating system |
+| Command | The exact command or API call that failed |
+| Expected result | What you expected to happen |
+| Actual result | What happened instead |
Do not include API keys, tokens, private memory files, or private filesystem
paths in public issues.
diff --git a/docs/integrations.md b/docs/integrations.md
index f3936d3..1dcb7bc 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -29,7 +29,7 @@ skill loops should fail fast instead of parsing free-form model text.
| Host | Status | Notes |
| -------- | ---------------------------- | ---------------------------------------------------------------------------------------- |
-| Pi | Implemented first-class path | Adapter, HarnessPort, lifecycle mapping, and canonical model mapping are implemented |
+| Pi | Implemented first-class path | Adapter, HarnessPort, lifecycle mapping, and native pi-ai model binding are implemented |
| Hermes | Implemented plugin path | MemoryProvider plugin uses Hermes' host-owned model/auth channel for write-side loops |
| OpenClaw | Implemented plugin path | Load the adapter as OpenClaw's memory slot; hooks provide recall, extraction, and skills |
| OpenCode | Implemented plugin path | Load the adapter as an OpenCode plugin; hooks provide recall, extraction, and skills |
@@ -147,10 +147,10 @@ pnpm -r build
USE_FAKE=1 node examples/pi/run.mjs
```
-`@earendil-works/pi-ai` is declared as a Pi peer dependency because Pi provides
-its own core packages to extensions. The extension imports `completeSimple` from
-`@earendil-works/pi-ai/compat`; MemFlywheel must not bundle Pi core packages into
-its tarball.
+`@earendil-works/pi-ai` is a runtime dependency of the adapter package. The Pi
+extension imports `completeSimple` from `@earendil-works/pi-ai/compat`, while the
+OpenCode bridge uses pi-ai's provider APIs. The dependency remains external to
+the adapter bundle and is installed alongside it.
## Hermes Integration
@@ -255,17 +255,42 @@ OpenCode keeps model configuration, tools, permissions, and sessions. The
MemFlywheel plugin uses OpenCode hooks for prompt recall, turn-end extraction,
source traces, skill evolution, and dream consolidation.
-If your OpenCode model credentials are stored only inside OpenCode, also export
-an OpenAI-compatible write-side model for MemFlywheel before starting OpenCode:
+The plugin resolves a pi-ai `Model` and `StreamFn` from OpenCode's active
+`chat.params` model context. The model id, resolved endpoint, and credential
+therefore come from OpenCode; no separate `MEMFLYWHEEL_LLM_*` configuration is
+read. Pi Agent Core owns the extraction, skill-evolution, and dream tool loops.
-```sh
-export MEMFLYWHEEL_LLM_ENDPOINT="https://api.example.com/v1"
-export MEMFLYWHEEL_LLM_API_KEY="..."
-export MEMFLYWHEEL_LLM_MODEL="deepseek-chat"
+On load, the plugin adds a narrow global `external_directory` allow rule for
+the active MemFlywheel root. This lets OpenCode's own Agent use its native
+`read` tool for progressive recall in every Session while leaving all other
+external directories unchanged. For the default macOS path, the equivalent
+manual configuration is:
+
+```json
+{
+ "permission": {
+ "external_directory": {
+ "*": "ask",
+ "/Users/you/.config/opencode/memflywheel/*": "allow"
+ }
+ }
+}
```
-Prompt recall and embedding pre-recall do not need these variables. Turn-end
-extraction, skill evolution, and dream consolidation do.
+The automatic rule follows `MEMFLYWHEEL_HOME`, `OPENCODE_CONFIG_DIR`, and
+`XDG_CONFIG_HOME` when those variables change the memory root.
+
+The OpenCode bridge maps the active host transport to pi-ai's native provider
+streams. OpenAI-compatible, OpenAI Responses, Anthropic, Google Gemini,
+Google Vertex, Amazon Bedrock, and Mistral transports are supported. The model
+id, endpoint, credential, headers, token limits, and relevant provider options
+still come from OpenCode. An unmapped transport fails during model binding
+instead of silently switching models or protocols.
+
+For `@ai-sdk/anthropic`, configure OpenCode's `baseURL` through the `/v1` prefix.
+OpenCode appends `/messages`; the MemFlywheel bridge removes that terminal `/v1`
+before handing the same endpoint to pi-ai, whose Anthropic SDK appends
+`/v1/messages` itself. Both paths therefore reach the same Messages endpoint.
For non-interactive `opencode run` tests, remember that OpenCode may reject file
reads outside `--dir`. If the model needs to inspect
@@ -286,18 +311,11 @@ The `plugins.slots.memory` setting is required because OpenClaw enables exactly
one memory plugin slot. If the slot still points at `memory-core`, the
MemFlywheel package is installed but inactive.
-If your OpenClaw model credentials are stored only inside OpenClaw, also export
-an OpenAI-compatible write-side model for MemFlywheel before starting the
-gateway or running `openclaw agent --local`:
-
-```sh
-export MEMFLYWHEEL_LLM_ENDPOINT="https://api.example.com/v1"
-export MEMFLYWHEEL_LLM_API_KEY="..."
-export MEMFLYWHEEL_LLM_MODEL="deepseek-chat"
-```
-
-Prompt recall and embedding pre-recall do not need these variables. Turn-end
-extraction, skill evolution, and dream consolidation do.
+The plugin uses OpenClaw's native
+`prepareSimpleCompletionModelForAgent`/`completeWithPreparedSimpleCompletionModel`
+path. OpenClaw resolves the active agent model and credential; no separate
+write-side LLM configuration is read. The adapter exposes that transport as a
+pi-ai stream; Pi Agent Core remains the only tool-loop implementation.
After a real session, MemFlywheel files should appear under
`~/.openclaw/memflywheel/`, including `MEMORY.md`, typed memory documents,
@@ -309,7 +327,7 @@ source traces, and learned skills.
| --------------------- | ------------------------------------------------------------------ |
| Lifecycle mapping | Translate host events into SDK hooks |
| Payload normalization | Convert host transcript/tool trajectory into `ExtractionMessage[]` |
-| Model port | Wrap host-owned model access into the canonical tool-call protocol |
+| Model binding | Resolve host-owned access into a pi-ai `Model` + `StreamFn` |
| Capability gate | Report recall-only, memory-loop, or skill-loop support |
| Installation | Apply and verify host-side wiring without changing core semantics |
diff --git a/docs/release.md b/docs/release.md
index ace283b..9994f54 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -31,12 +31,12 @@ GitHub Action.
Internal workspace packages:
-| Package | Purpose |
-| --------------------- | ----------------------------------------------------------------------- |
-| `@memflywheel/core` | File-backed memory kernel |
-| `@memflywheel/model` | Provider-neutral model protocol and OpenAI-compatible mappers |
-| `@memflywheel/sdk` | Host lifecycle SDK and memory/dream/skill loops |
-| `@memflywheel/skills` | Learned-skill package store, validation, finalize, rollback, and recall |
+| Package | Purpose |
+| ------------------------- | ----------------------------------------------------------------------- |
+| `@memflywheel/core` | File-backed memory kernel |
+| `@memflywheel/embeddings` | Optional embedding pre-recall provider |
+| `@memflywheel/sdk` | Host lifecycle SDK and memory/dream/skill loops |
+| `@memflywheel/skills` | Learned-skill package store, validation, finalize, rollback, and recall |
Publish packages in dependency order:
diff --git a/examples/README.md b/examples/README.md
index 6cb31ab..ba43ac5 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,68 +1,21 @@
-# MemFlywheel integration examples
-
-One runnable minimal integration per targeted host. Each example wraps the host's
-own tool-calling LLM channel into a canonical model or `HostHarnessPort`, builds a
-batteries-included scribe with `createMemFlywheelHarnessRuntime`, mounts the full
-lifecycle (session start / prompt build / turn end / idle), and installs +
-round-trip-verifies the host wiring with `connect`.
-
-Extraction is a tool-calling subagent loop: the model is handed core's
-memory-write tools and writes memory files directly. Every example ships **two**
-paths:
-
-- a deterministic, offline **fake canonical model** (used by the smoke test /
- CI): it scripts a multi-step subagent (list -> save two memories -> decline a
- high-risk secret), and
-- the **real canonical model** (the host's model channel, or the OpenAI-compatible
- mapper from `@memflywheel/model` for standalone examples).
-
-Set `USE_FAKE=1` to force the offline path (the default in CI).
-
-## Layout
-
-```
-examples/
- README.md # this file
- shared/
- fake-model.mjs # scripted offline subagent (list -> save -> decline)
- transcript.mjs # sample transcript (a preference + a secret to decline)
- pi/
- extension.mjs # Pi extension entry: createPiHarnessPort, attach scribe
- run.mjs # mock Pi host driving the full lifecycle
- README.md
- hermes/
- plugin-register.mjs # register(ctx): wrap ctx.llm.completeWithTools as canonical model
- run.mjs # mock Hermes host driving the full lifecycle
- README.md
- openclaw/
- plugin.mjs # register(api): registerMemoryCapability + recall-only hooks
- run.mjs # mock OpenClaw host; explicit recall-only mode
- README.md
+# MemFlywheel integration entries
+
+These files show the host-owned installation boundary only. Model routing, credentials, and provider selection remain inside the active host; there is no standalone write-side LLM configuration or mock model path.
+
+```text
+Host lifecycle + active model
+ |
+ HostHarnessPort
+ |
+ Model + StreamFn resolver
+ |
+ Pi Agent Core
+ / | \
+Extraction Dream Skill Evolution
```
-## LLM environment (real path)
-
-When not using the fake, `createOpenAIChatCompletionsModel` (an OpenAI-compatible
-`/chat/completions` mapper with a `tools` array) reads:
-
-| Variable | Meaning |
-| ---------------------------- | ------------------------------- |
-| `MEMFLYWHEEL_LLM_ENDPOINT` | base URL override |
-| `MEMFLYWHEEL_LLM_API_KEY` | key (fallback `OPENAI_API_KEY`) |
-| `MEMFLYWHEEL_LLM_MODEL` | model id |
-| `MEMFLYWHEEL_LLM_MAX_TOKENS` | response cap |
-
-## Running
-
-Build the workspace first (`pnpm -r build`), then:
-
-```bash
-USE_FAKE=1 node examples/pi/run.mjs
-USE_FAKE=1 node examples/hermes/run.mjs
-USE_FAKE=1 node examples/openclaw/run.mjs
-```
+- `pi/extension.mjs` — Pi extension entry.
+- `hermes/plugin-register.mjs` — Hermes plugin registration sketch; the published Python MemoryProvider contains the production bridge.
+- `openclaw/plugin.mjs` — OpenClaw plugin entry.
-Each `run.mjs` drives `onSessionStart → onPromptBuild → onTurnEnd → context()`,
-prints the resulting `MEMORY.md`, and (under `USE_FAKE=1`) acts as a smoke test:
-it exits non-zero unless the subagent's two memories were written and the
-high-risk secret was kept off disk.
+Run `pnpm --dir examples smoke` after building the workspace to syntax-check these entries.
diff --git a/examples/dream-regression.mjs b/examples/dream-regression.mjs
deleted file mode 100644
index 4c1213f..0000000
--- a/examples/dream-regression.mjs
+++ /dev/null
@@ -1,231 +0,0 @@
-/**
- * Real-model regression for the dream consolidation subagent via a
- * tool-calling model. Seeds a deliberately messy store, then runs a full dream pass:
- *
- * 1. the deterministic structural pre-pass (delete identical-body duplicates,
- * relocate a misfiled memory) — LLM-free, guaranteed; then
- * 2. the consolidation subagent, which reads full bodies and merges near-
- * duplicates / compresses over-long notes via ordinary file tools.
- *
- * It wraps every ordinary file tool to log the subagent's real tool calls, then verifies
- * the deterministic outcomes strictly and the semantic work by "no data loss".
- *
- * Run (uses YOUR key; never hardcode it):
- * MEMFLYWHEEL_LLM_ENDPOINT= \
- * MEMFLYWHEEL_LLM_MODEL= \
- * MEMFLYWHEEL_LLM_API_KEY= \
- * node examples/dream-regression.mjs
- */
-import { mkdtemp, mkdir, writeFile, readFile, readdir } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import { createMemFlywheel, runDreamAgent } from "@memflywheel/sdk";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-
-const root = await mkdtemp(path.join(tmpdir(), "memflywheel-dream-rr-"));
-
-async function seed(relativePath, frontmatter, body) {
- const full = path.join(root, relativePath);
- await mkdir(path.dirname(full), { recursive: true });
- const fm = Object.entries(frontmatter)
- .map(([k, v]) => `${k}: ${v}`)
- .join("\n");
- await writeFile(full, `---\n${fm}\n---\n\n${body}\n`, "utf8");
-}
-
-// --- a realistic, messy developer memory store that genuinely needs cleanup ---
-
-// Stable identity (should be left alone).
-await seed(
- "identity/role.md",
- { name: "Role", type: "identity" },
- "A backend engineer who mainly writes Go.",
-);
-
-// Two same-topic beverage preferences accumulated on different days — the subagent
-// should MERGE these into one drinks preference, keeping BOTH items (no data loss).
-await seed(
- "preference/tea.md",
- { name: "Green tea", type: "preference" },
- "Prefers green tea as the daily drink.",
-);
-await seed(
- "preference/coffee.md",
- { name: "Coffee", type: "preference" },
- "Also enjoys an americano in the afternoon.",
-);
-
-// Two same-topic team notes — candidates to merge into one roster, keeping both people.
-await seed("ambient/mara.md", { name: "Mara", type: "ambient" }, "Mara is the backend team lead.");
-await seed(
- "ambient/jin.md",
- { name: "Jin", type: "ambient" },
- "Jin runs the infrastructure and the on-call rotation.",
-);
-
-// Exact-duplicate bodies -> deterministic delete-duplicate (LLM-free).
-await seed(
- "style/brevity.md",
- { name: "Brevity", type: "style" },
- "Keep replies short and to the point.",
-);
-await seed(
- "style/short.md",
- { name: "Be concise", type: "style" },
- "Keep replies short and to the point.",
-);
-
-// Misfiled: lives in identity/ but declares type preference -> deterministic relocate.
-await seed(
- "identity/editor.md",
- { name: "Editor", type: "preference" },
- "Uses Neovim as the main editor.",
-);
-
-// An over-long workflow note holding a full numbered SOP — complete methods belong
-// in a skill, so the subagent should compress this to a short trigger signal.
-await seed(
- "workflow/debugging.md",
- { name: "Debugging routine", type: "workflow" },
- [
- "When a production bug comes in, the routine is: 1) reproduce it locally with the exact input;",
- "2) read the full stack trace and find the first frame in our own code; 3) add a failing test that",
- "captures the bug; 4) bisect recent commits if it is a regression; 5) check the logs and metrics",
- "dashboard for the time window; 6) verify the config and feature flags; 7) fix the root cause, not",
- "the symptom; 8) run the full suite; 9) write a short postmortem note.",
- ].join(" "),
-);
-
-const model = createOpenAIChatCompletionsModel({
- endpoint: process.env.MEMFLYWHEEL_LLM_ENDPOINT,
- apiKey: process.env.MEMFLYWHEEL_LLM_API_KEY,
- model: process.env.MEMFLYWHEEL_LLM_MODEL,
-});
-
-// A dreamRunner that wraps core's tools with a tracer, then drives the real loop.
-const trace = [];
-const dreamRunner = async (input) => {
- const tracedTools = input.tools.map((t) => ({
- ...t,
- handler: async (args, tc) => {
- const res = await t.handler(args, tc);
- trace.push({ tool: t.name, args, ok: res.ok, text: res.text });
- return res;
- },
- }));
- return runDreamAgent({
- model,
- tools: tracedTools,
- toolCtx: input.toolCtx,
- health: input.health,
- typeReview: input.typeReview,
- manifest: input.manifest,
- index: input.index,
- coordination: input.coordination,
- maxSteps: 18,
- });
-};
-
-const scribe = createMemFlywheel({ root, dreamRunner });
-
-async function listFiles() {
- const out = [];
- for (const type of ["identity", "preference", "style", "workflow", "context", "ambient"]) {
- let files = [];
- try {
- files = await readdir(path.join(root, type));
- } catch {}
- for (const f of files) out.push(`${type}/${f}`);
- }
- return out;
-}
-
-console.log("库:", root);
-console.log("整理前文件:", await listFiles());
-
-const result = await scribe.runDream({
- reason: "idle",
- memoryAction: "consolidate",
- topics: ["drinks", "team"],
-});
-console.log(`\n===== dream pass =====`);
-console.log(`ran=${result.ran} reason=${result.reason}`);
-console.log(
- `deterministic changed=${result.changed?.length ?? 0} deleted=${result.deleted?.length ?? 0}`,
-);
-
-console.log(`\n----- subagent 真实 tool calls (${trace.length}) -----`);
-for (const c of trace) {
- const args = JSON.stringify(c.args);
- const text = (c.text || "").replace(/\s+/g, " ").slice(0, 120);
- console.log(
- ` ${c.ok ? "ok " : "ERR"} ${c.tool}(${args.length > 180 ? args.slice(0, 180) + "…" : args})`,
- );
- console.log(` -> ${text}`);
-}
-
-// --- verification ---
-console.log("\n\n========== 校验 ==========");
-const files = await listFiles();
-console.log("整理后文件:", files);
-
-const styleCount = files.filter((f) => f.startsWith("style/")).length;
-console.log(
- `\n[确定性] 重复 style 去重: ${styleCount === 1 ? "✅ 仅剩 1 个" : `❌ 剩 ${styleCount} 个`}`,
-);
-
-const relocated =
- !files.includes("identity/editor.md") && files.some((f) => f === "preference/editor.md");
-console.log(
- `[确定性] 放错目录搬迁 (identity/editor→preference/editor): ${relocated ? "✅" : "❌"}`,
-);
-
-async function bodyOfDir(prefix) {
- let text = "";
- for (const f of files.filter((f) => f.startsWith(prefix))) {
- text += "\n" + (await readFile(path.join(root, f), "utf8"));
- }
- return text;
-}
-
-const prefText = await bodyOfDir("preference/");
-const hasTea = /green tea|绿茶/i.test(prefText);
-const hasCoffee = /coffee|americano|咖啡/i.test(prefText);
-const prefCount = files.filter((f) => f.startsWith("preference/")).length;
-console.log(
- `[语义] 饮料偏好整理: ${prefCount} 个 preference 文件; 含茶 ${hasTea ? "✅" : "❌"} 含咖啡 ${hasCoffee ? "✅" : "❌"}` +
- ` → ${hasTea && hasCoffee ? "无数据丢失 ✅" : "可能丢数据 ⚠️"}`,
-);
-
-const ambientText = await bodyOfDir("ambient/");
-const hasMara = /mara/i.test(ambientText);
-const hasJin = /jin/i.test(ambientText);
-const ambientCount = files.filter((f) => f.startsWith("ambient/")).length;
-console.log(
- `[语义] 团队记忆整理: ${ambientCount} 个 ambient 文件; 含 Mara ${hasMara ? "✅" : "❌"} 含 Jin ${hasJin ? "✅" : "❌"}` +
- ` → ${hasMara && hasJin ? "无数据丢失 ✅" : "可能丢数据 ⚠️"}`,
-);
-
-const wf = files.find((f) => f.startsWith("workflow/"));
-if (wf) {
- const body = (await readFile(path.join(root, wf), "utf8"))
- .split(/---/)
- .slice(2)
- .join("---")
- .trim();
- const hasNumberedSteps = /\b[3-9]\)/.test(body);
- console.log(
- `[语义] 超长 workflow 压缩: ${wf} 正文 ${body.length} 字符; ${hasNumberedSteps ? "仍含多步编号 ⚠️" : "已压成短触发 ✅"}`,
- );
-}
-
-console.log("\n----- 最终正文 -----");
-for (const f of files) {
- const raw = await readFile(path.join(root, f), "utf8");
- const body = raw.split(/---/).slice(2).join("---").trim().replace(/\s+/g, " ");
- console.log(` ${f}: ${body.slice(0, 140)}`);
-}
-
-const counts = {};
-for (const c of trace) counts[c.tool] = (counts[c.tool] || 0) + 1;
-console.log("\n工具调用统计:", JSON.stringify(counts));
diff --git a/examples/extraction-regression.mjs b/examples/extraction-regression.mjs
deleted file mode 100644
index dcf4ec3..0000000
--- a/examples/extraction-regression.mjs
+++ /dev/null
@@ -1,259 +0,0 @@
-/**
- * Real-model regression for the extraction subagent via tool-calling.
- * Wraps every ordinary file tool to log the subagent's real tool calls (name + args +
- * result), then drives multi-round scenarios that exercise the full
- * locate -> read -> save/update(append)/archive flow, plus noise & privacy.
- */
-import { mkdtemp, readFile, readdir } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import {
- createFileTools,
- runExtractionAgent,
- buildExtractionAgentUserMessage,
-} from "@memflywheel/sdk";
-import {
- createAuditLogger,
- createMemoryFileToolContext,
- formatManifest,
- scanMemoryFiles,
-} from "@memflywheel/core";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-
-const root = await mkdtemp(path.join(tmpdir(), "memflywheel-rr-"));
-const toolCtx = createMemoryFileToolContext({ ctx: { root, audit: createAuditLogger(root) } });
-
-const trace = [];
-const tools = createFileTools().map((t) => ({
- ...t,
- handler: async (args, tc = toolCtx) => {
- const res = await t.handler(args, tc);
- trace.push({ tool: t.name, args, ok: res.ok, text: res.text });
- return res;
- },
-}));
-
-const model = createOpenAIChatCompletionsModel({
- endpoint: process.env.MEMFLYWHEEL_LLM_ENDPOINT,
- apiKey: process.env.MEMFLYWHEEL_LLM_API_KEY,
- model: process.env.MEMFLYWHEEL_LLM_MODEL,
-});
-const highRiskCardNumber = "62" + "22 0212 3456 7890";
-
-async function manifest() {
- return formatManifest(await scanMemoryFiles(root));
-}
-
-async function listMemoryFiles() {
- const out = [];
- for (const type of ["identity", "preference", "style", "workflow", "context", "ambient"]) {
- let files = [];
- try {
- files = await readdir(path.join(root, type));
- } catch {}
- for (const f of files) out.push(`${type}/${f}`);
- }
- return out;
-}
-
-async function scenario(label, messages, opts = {}) {
- const start = trace.length;
- const m = await manifest();
- if (opts.showSeed) {
- // Print exactly what the extraction subagent receives — proves the
- // tool calls were folded into text and truncated before the model sees them.
- const seed = buildExtractionAgentUserMessage({ messages, manifest: m });
- const convo = seed.slice(seed.indexOf("# Recent conversation"));
- console.log(`\n----- 折叠后喂给模型的提取上下文 (${label}) -----`);
- console.log(
- convo
- .split("\n")
- .map((l) => " | " + l)
- .join("\n"),
- );
- }
- const result = await runExtractionAgent({
- model,
- tools,
- toolCtx,
- messages,
- manifest: m,
- maxSteps: 10,
- });
- console.log(`\n===== ${label} =====`);
- console.log(
- `steps=${result.steps} stopped=${result.stoppedReason} tool-calls=${result.toolCalls.length}`,
- );
- for (const c of trace.slice(start)) {
- const args = JSON.stringify(c.args);
- const text = (c.text || "").replace(/\s+/g, " ").slice(0, 110);
- console.log(
- ` ${c.ok ? "ok " : "ERR"} ${c.tool}(${args.length > 160 ? args.slice(0, 160) + "…" : args})`,
- );
- console.log(` -> ${text}`);
- }
- return result;
-}
-
-// Scenario A — first turn: identity + style + a list-type preference (green tea) + noise.
-await scenario("A. 首轮提取(身份/风格/偏好 + 噪声)", [
- { role: "user", text: "我是后端工程师,主要写 Go。回答我尽量简洁,用中文。另外我平时爱喝绿茶。" },
- { role: "assistant", text: "好的。后端 Go、简洁中文、爱绿茶。" },
- { role: "user", text: "今天周几?顺便看下这个 nil pointer 报错。" },
- { role: "assistant", text: "今天周一。空指针通常是解引用了未初始化变量……" },
-]);
-
-// Scenario B — THE equivalence test: append a second drink; must search/read then update WITHOUT losing green tea.
-await scenario("B. 列表型偏好追加(必须 search/read→update 不丢绿茶)", [
- { role: "user", text: "对了,我也很爱喝美式咖啡。" },
- { role: "assistant", text: "记住了,美式咖啡。" },
-]);
-
-// Scenario C — high-risk privacy: must NOT write.
-await scenario("C. 高风险隐私(应拒记)", [
- { role: "user", text: `存一下我的银行卡号 ${highRiskCardNumber},别忘了。` },
- { role: "assistant", text: "收到。" },
-]);
-
-// Scenario D — explicit correction: should update/archive identity.
-await scenario("D. 显式纠正(身份变更)", [
- { role: "user", text: "其实别叫我工程师了,我现在是技术经理,带一个后端团队。" },
- { role: "assistant", text: "明白,技术经理。" },
-]);
-
-// Scenario E — THE folding test: the memorable fact (project uses pnpm) appears
-// ONLY inside a tool call, never in user/assistant text. Plus a giant tool output
-// that must be truncated (input 200 / output 500 head+tail) so size never matters.
-const hugeLog =
- "Resolving dependencies…\n" +
- Array.from({ length: 3000 }, (_, i) => ` + dep-${i}@1.${i}.0 (cached)`).join("\n") +
- "\nLockfile is up to date, resolution step is skipped\n" +
- "Done in 9.7s\nTest Suites: 14 passed, 14 total\nTests: 87 passed, 87 total";
-console.log(`\n[巨型工具输出原始长度: ${hugeLog.length} 字符 — 折叠后应被截到 ~500]`);
-await scenario(
- "E. 仅工具调用里的事实(项目用 pnpm)+ 巨型输出截断",
- [
- { role: "user", text: "帮我把依赖装上,然后把测试整套跑一遍。" },
- {
- role: "assistant",
- text: "好的,我来装依赖并运行测试。",
- toolCalls: [
- {
- name: "Bash",
- input: {
- command: "pnpm install --frozen-lockfile && pnpm run test:all",
- description: "安装依赖并运行全部测试",
- },
- output: hugeLog,
- },
- ],
- },
- { role: "assistant", text: "依赖装好了,87 个测试全部通过。" },
- ],
- { showSeed: true },
-);
-
-// Scenario F — folding drives a REAL save: the durable facts (pnpm@9.7, node>=20,
-// vitest, tsup) live ONLY in the tool output, and the user explicitly asks to
-// remember the toolchain long-term (explicit-intent override). A clear, fair test
-// that folded tool content is not just present but actionable by the model.
-await scenario(
- "F. 工具调用里的持久事实 + 用户明确要求长期遵循",
- [
- {
- role: "user",
- text: "以后你给我的命令都要符合我们项目的实际配置,先记住我们的工具链,长期按这个来。",
- },
- {
- role: "assistant",
- text: "好的,我读一下项目配置。",
- toolCalls: [
- {
- name: "Read",
- input: { file_path: "package.json" },
- output: JSON.stringify(
- {
- name: "acme-api",
- packageManager: "pnpm@9.7.0",
- engines: { node: ">=20" },
- scripts: { test: "vitest", build: "tsup", lint: "eslint ." },
- },
- null,
- 2,
- ),
- },
- ],
- },
- { role: "assistant", text: "看到了,后续我都会按项目实际配置来。" },
- ],
- { showSeed: true },
-);
-
-// ---- verification ----
-console.log("\n\n========== 校验 ==========");
-const files = await listMemoryFiles();
-console.log("最终记忆文件:", files);
-
-const drinkFile =
- files.find((f) => /drink|tea|coffee|beverage|饮/.test(f.toLowerCase())) ||
- files.find((f) => f.startsWith("preference/"));
-if (drinkFile) {
- const body = await readFile(path.join(root, drinkFile), "utf8");
- const hasTea = /绿茶|green tea/i.test(body);
- const hasCoffee = /咖啡|coffee|americano/i.test(body);
- console.log(`\n饮料记忆文件: ${drinkFile}`);
- console.log(` 含绿茶: ${hasTea ? "✅" : "❌"} 含咖啡: ${hasCoffee ? "✅" : "❌"}`);
- console.log(
- ` → 追加${hasTea && hasCoffee ? "成功,渐进 update 未丢数据 ✅" : "可能丢数据 ⚠️ (见正文)"}`,
- );
- console.log(
- " 正文:\n" +
- body
- .split(/\n/)
- .filter(Boolean)
- .map((l) => " " + l)
- .join("\n"),
- );
-}
-
-let leak = false;
-for (const f of files) {
- if ((await readFile(path.join(root, f), "utf8")).includes(highRiskCardNumber.slice(0, 4)))
- leak = true;
-}
-console.log(`\n隐私: 银行卡号 ${leak ? "❌ 泄露!" : "✅ 未进入任何记忆"}`);
-
-// Folding: the toolchain facts (pnpm / vitest / tsup / node>=20) existed ONLY in
-// tool-call outputs. If any memory now mentions them, the folded tool text reached
-// the model AND was extracted — the feature delivering value end-to-end.
-let foldFile = null;
-for (const f of files) {
- const body = await readFile(path.join(root, f), "utf8");
- if (/pnpm|vitest|tsup|工具链|包管理|packageManager|node\s*>=?\s*20/i.test(body)) {
- foldFile = `${f}\n 正文: ${body
- .split(/\n/)
- .filter((l) => l && !l.startsWith("---") && !/^\w+:/.test(l))
- .join(" ")
- .trim()}`;
- break;
- }
-}
-console.log(
- `\n折叠(仅工具调用里的事实): ${
- foldFile
- ? `✅ 提取到工具链事实 → ${foldFile}`
- : "⚠️ 未存成记忆(看上面的折叠上下文确认模型是否收到工具链信息)"
- }`,
-);
-// Truncation: the 100k+ char tool log must NOT have leaked verbatim into any memory.
-let logLeak = false;
-for (const f of files) {
- if ((await readFile(path.join(root, f), "utf8")).includes("dep-2999")) logLeak = true;
-}
-console.log(`巨型输出截断: ${logLeak ? "❌ 原始日志泄露进记忆" : "✅ 未泄露(已截断)"}`);
-
-// tool usage summary
-const counts = {};
-for (const c of trace) counts[c.tool] = (counts[c.tool] || 0) + 1;
-console.log("\n工具调用统计:", JSON.stringify(counts));
-console.log("库:", root);
diff --git a/examples/hermes/README.md b/examples/hermes/README.md
index 747a3a8..04569d9 100644
--- a/examples/hermes/README.md
+++ b/examples/hermes/README.md
@@ -1,32 +1,5 @@
-# Hermes integration example
+# Hermes integration
-Real integration: a Hermes plugin's `register(ctx)` wraps the host LLM facade
-(`ctx.llm.completeWithTools`, with tool calling) into a canonical model, builds
-the scribe with `createMemFlywheelHarnessRuntime`, and binds the `hermes` adapter
-to Hermes' real hooks.
+The published Hermes MemoryProvider keeps model routing and credentials inside Hermes. Its Python bridge converts one Pi context request into `agent.auxiliary_client.call_llm`, returns one native Pi assistant message, and leaves all tool iteration to Pi Agent Core.
-## Lifecycle mapping
-
-| Hermes hook | scribe hook | what the adapter does |
-| ------------------ | ---------------- | ------------------------------------------------------------ |
-| `on_session_start` | `onSessionStart` | ensure memory dir + register session |
-| `pre_llm_call` | `onPromptBuild` | inject prelude as `{"context": ...}`; merge rules |
-| `post_llm_call` | `onTurnEnd` | fire-and-forget extraction subagent (`user_message` + reply) |
-| `on_session_end` | `onIdle` | gate-checked dream consolidation |
-
-Because Hermes owns the credentials, **no API key is needed** — the extraction
-subagent runs on Hermes' own model through `ctx.llm.completeWithTools`.
-
-## Files
-
-- `plugin-register.mjs` — `register(ctx)`: wrap `ctx.llm.completeWithTools` as a
- canonical model, bridge `ctx.register_hook` into the adapter's `on(event)`
- surface, `attach`.
-- `run.mjs` — a mock Hermes host driving the four hooks + `connect`.
-
-## Run the smoke test
-
-```bash
-pnpm -r build
-USE_FAKE=1 node examples/hermes/run.mjs
-```
+`plugin-register.mjs` documents the host registration boundary; production installation uses `@iflytekopensource/hermes`.
diff --git a/examples/hermes/e2e-strict.mjs b/examples/hermes/e2e-strict.mjs
deleted file mode 100644
index 6394faa..0000000
--- a/examples/hermes/e2e-strict.mjs
+++ /dev/null
@@ -1,343 +0,0 @@
-/**
- * MemFlywheel × Hermes — STRICT end-to-end test (real model).
- *
- * This test validates the full memory + skill closed loop with Hermes adapter
- * against a real LLM model. All assertions are pass/fail, and the process exits
- * non-zero on ANY fail.
- *
- * SECURITY: the key is read ONLY from the env; never hardcoded, never written to a
- * file, never stored in the capture log (only forwarded upstream in the header).
- *
- * export MEMFLYWHEEL_LLM_API_KEY=sk-...
- * export MEMFLYWHEEL_LLM_ENDPOINT=https://api.deepseek.com/v1
- * export MEMFLYWHEEL_LLM_MODEL=deepseek-v4-flash
- * node examples/hermes/e2e-strict.mjs
- */
-
-import { mkdtemp, mkdir, readFile } from "node:fs/promises";
-import { createServer } from "node:http";
-import https from "node:https";
-import { tmpdir } from "node:os";
-import path from "node:path";
-
-import { createMemFlywheelHarnessRuntime, hermesAdapter } from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import { createFakeModel } from "../shared/fake-model.mjs";
-
-// ───────────────────────────── config ──────────────────────────────────────
-
-const useFake = process.env.USE_FAKE === "1";
-const ENDPOINT =
- process.env.MEMFLYWHEEL_LLM_ENDPOINT ??
- process.env.DEEPSEEK_BASE_URL ??
- "https://api.deepseek.com/v1";
-const MODEL =
- process.env.MEMFLYWHEEL_LLM_MODEL ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash";
-const API_KEY =
- process.env.MEMFLYWHEEL_LLM_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY;
-
-if (!API_KEY && !useFake) {
- console.error("❌ MEMFLYWHEEL_LLM_API_KEY is required");
- process.exit(1);
-}
-
-let ACTIVE_ENDPOINT = ENDPOINT;
-let ACTIVE_API_KEY = API_KEY;
-const proxyLog = [];
-
-function buildModel() {
- if (useFake) return createFakeModel();
- return createOpenAIChatCompletionsModel({
- endpoint: ACTIVE_ENDPOINT,
- apiKey: ACTIVE_API_KEY,
- model: MODEL,
- maxTokens: 4096,
- temperature: 0,
- });
-}
-
-// ─────────────────────────── result bookkeeping ────────────────────────────
-
-const results = [];
-function record(name, status, detail = "") {
- results.push({ name, status, detail });
- const mark = status === "pass" ? "✅" : status === "skip" ? "·" : "❌";
- console.log(` ${mark} ${name}${detail ? ` — ${detail}` : ""}`);
-}
-function check(name, cond, detail = "") {
- record(name, cond ? "pass" : "fail", cond ? "" : detail || "assertion failed");
-}
-function banner(t) {
- console.log(`\n── ${t}`);
-}
-const by = (s) => results.filter((r) => r.status === s).length;
-
-// ─────────────────────────── capture proxy ─────────────────────────────────
-
-function summarize(body) {
- const messages = Array.isArray(body?.messages) ? body.messages : [];
- const tools = Array.isArray(body?.tools) ? body.tools : [];
- return {
- model: body?.model,
- roles: messages.map((m) => m?.role),
- systemHead: (messages.find((m) => m?.role === "system")?.content ?? "").slice(0, 400),
- toolNames: tools.map((t) => t?.function?.name ?? t?.name).filter(Boolean),
- hasToolResult: messages.some((m) => m?.role === "tool"),
- };
-}
-
-function startProxy() {
- return new Promise((resolve) => {
- const server = createServer((req, res) => {
- let buf = "";
- req.on("data", (c) => (buf += c));
- req.on("end", () => {
- const body = buf ? JSON.parse(buf) : {};
- proxyLog.push({ path: req.url, summary: summarize(body) });
-
- const target = new URL(req.url, ACTIVE_ENDPOINT);
- const proxyReq = https.request(
- target,
- {
- method: req.method,
- headers: {
- ...req.headers,
- host: target.host,
- authorization: `Bearer ${ACTIVE_API_KEY}`,
- },
- },
- (proxyRes) => {
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res);
- },
- );
- proxyReq.on("error", (e) => {
- console.error("proxy error:", e.message);
- res.writeHead(502);
- res.end("proxy error");
- });
- if (buf) proxyReq.write(buf);
- proxyReq.end();
- });
- });
- server.listen(0, "127.0.0.1", () => {
- const { port } = server.address();
- resolve({ server, port, url: `http://127.0.0.1:${port}` });
- });
- });
-}
-
-// ─────────────────────────── mock hermes host ──────────────────────────────
-
-function createMockHermesHost() {
- const listeners = new Map();
- return {
- on(event, fn) {
- const set = listeners.get(event) ?? new Set();
- set.add(fn);
- listeners.set(event, set);
- return () => set.delete(fn);
- },
- emit(event, payload) {
- for (const fn of listeners.get(event) ?? []) fn(payload);
- },
- };
-}
-
-// ────────────────────────────── test scenario ──────────────────────────────
-
-async function main() {
- console.log("🧪 MemFlywheel × Hermes E2E (strict)");
- if (useFake) {
- console.log(" mode: FAKE (offline)");
- } else {
- console.log(` endpoint: ${ENDPOINT}`);
- console.log(` model: ${MODEL}`);
- }
-
- let proxy;
- if (!useFake) {
- proxy = await startProxy();
- console.log(` proxy: ${proxy.url}\n`);
- ACTIVE_ENDPOINT = proxy.url;
- }
-
- const root = await mkdtemp(path.join(tmpdir(), "memflywheel-hermes-e2e-"));
- await mkdir(path.join(root, "skills"), { recursive: true });
-
- const model = buildModel();
- const { scribe } = createMemFlywheelHarnessRuntime({ model, root });
- const host = createMockHermesHost();
- const dispose = hermesAdapter.attach(scribe, host);
-
- try {
- // ── Session start
- banner("session lifecycle");
- host.emit("on_session_start", { session_id: "e2e-hermes" });
- check("session started", true);
-
- // ── Context injection (pre_llm_call)
- banner("context injection");
- let ctxPromise;
- host.emit("pre_llm_call", {
- session_id: "e2e-hermes",
- respond: (p) => (ctxPromise = p),
- });
- const ctx = await ctxPromise;
- check("context hook fired", Boolean(ctx));
- check("context enabled", ctx?.enabled !== false);
-
- // ── First turn: user shares preference
- banner("turn 1: user preference");
- const userMsg1 = {
- role: "user",
- content: [{ type: "text", text: "I love drinking green tea with honey in the mornings." }],
- };
-
- let assistantRes1;
- const pre1 = new Promise((r) =>
- host.emit("pre_llm_call", {
- session_id: "e2e-hermes",
- respond: r,
- }),
- );
- const preCtx1 = await pre1;
- check("pre_llm_call fired", Boolean(preCtx1));
-
- // Simulate assistant response
- assistantRes1 = {
- role: "assistant",
- content: [
- {
- type: "text",
- text: "That sounds like a wonderful morning ritual! Green tea with honey is both comforting and healthy.",
- },
- ],
- };
-
- await scribe.onTurnEnd({
- sessionId: "e2e-hermes",
- messages: [userMsg1, assistantRes1],
- });
-
- host.emit("post_llm_call", {
- session_id: "e2e-hermes",
- user_message: userMsg1,
- assistant_response: assistantRes1,
- });
- check("turn 1 completed", true);
-
- // ── Second turn: another preference
- banner("turn 2: another preference");
- const userMsg2 = {
- role: "user",
- content: [{ type: "text", text: "Please reply to me in a warm and friendly tone." }],
- };
-
- const pre2 = new Promise((r) =>
- host.emit("pre_llm_call", {
- session_id: "e2e-hermes",
- respond: r,
- }),
- );
- await pre2;
-
- const assistantRes2 = {
- role: "assistant",
- content: [
- {
- type: "text",
- text: "I'll make sure to keep things warm and friendly! Thanks for letting me know your preference.",
- },
- ],
- };
-
- await scribe.onTurnEnd({
- sessionId: "e2e-hermes",
- messages: [userMsg2, assistantRes2],
- });
-
- host.emit("post_llm_call", {
- session_id: "e2e-hermes",
- user_message: userMsg2,
- assistant_response: assistantRes2,
- });
- check("turn 2 completed", true);
-
- // ── Wait for idle
- banner("memory persistence");
- await scribe.onIdle({ force: true });
-
- // ── Session end
- banner("session shutdown");
- host.emit("on_session_end", { session_id: "e2e-hermes" });
- check("session ended", true);
-
- // ── Assertions
- banner("assertions");
-
- const index = await readFile(path.join(root, "MEMORY.md"), "utf8").catch(() => "");
- const memLen = index.length;
-
- if (useFake) {
- // Fake mode: just verify the lifecycle completed
- check("session lifecycle completed", true);
- record("MEMORY.md written", "skip", "fake mode does not produce memories");
- record("tea preference captured", "skip", "fake mode does not produce memories");
- record("tone preference captured", "skip", "fake mode does not produce memories");
- record("body index.json exists", "skip", "fake mode does not produce body files");
- } else {
- check("MEMORY.md written", memLen > 0, memLen ? `${memLen} bytes` : "empty");
-
- const hasTeaPref = /green tea|tea/i.test(index);
- check("tea preference captured", hasTeaPref, index.slice(0, 200));
-
- const hasTonePref = /tone|friendly|warm/i.test(index);
- check("tone preference captured", hasTonePref);
-
- const bodyDir = path.join(root, "body");
- const bodyFiles = await readFile(path.join(bodyDir, "index.json"), "utf8")
- .then((s) => JSON.parse(s))
- .catch(() => null);
- check("body index.json exists", Boolean(bodyFiles));
- }
-
- // ── Proxy assertions
- banner("proxy capture");
- if (useFake) {
- record("proxy captured requests", "skip", "fake mode");
- record("extraction call observed", "skip", "fake mode");
- } else {
- check("proxy captured requests", proxyLog.length > 0, `${proxyLog.length} requests`);
-
- const extraction = proxyLog.find((r) => /extract/i.test(JSON.stringify(r.summary)));
- if (extraction) {
- check("extraction call observed", true);
- check("extraction has tools", extraction.summary.toolNames.length > 0);
- } else {
- record("extraction call observed", "skip", "no extraction call found in proxy log");
- }
- }
-
- // ── Summary
- banner("summary");
- console.log(
- ` pass: ${by("pass")} fail: ${by("fail")} skip: ${by("skip")} total: ${results.length}`,
- );
- console.log(` root: ${root}`);
-
- if (by("fail") > 0) {
- console.error("\n❌ E2E FAILED");
- process.exit(1);
- }
- console.log("\n✅ E2E PASSED");
- } finally {
- dispose();
- if (proxy) proxy.server.close();
- }
-}
-
-main().catch((e) => {
- console.error("fatal:", e);
- process.exit(1);
-});
diff --git a/examples/hermes/plugin-register.mjs b/examples/hermes/plugin-register.mjs
index 8ccf1a0..32237f1 100644
--- a/examples/hermes/plugin-register.mjs
+++ b/examples/hermes/plugin-register.mjs
@@ -2,12 +2,12 @@
* Hermes plugin glue (real integration).
*
* A Hermes plugin's `register(ctx)` maps the host LLM facade into the canonical
- * model protocol, builds a MemFlywheel harness runtime, and binds `hermesAdapter`
+ * model transport, builds a MemFlywheel harness runtime, and binds `hermesAdapter`
* so the scribe's hooks fire on Hermes' real events.
*
* Because Hermes owns the credentials, no API key is needed — both subagents
* (extraction and dream consolidation) run on Hermes' own model through
- * `ctx.llm.completeWithTools`, over the single canonical model channel.
+ * the host's active model transport, over the single Pi Agent Core runner.
*/
import { createMemFlywheelHarnessRuntime, hermesAdapter } from "@iflytekopensource/adapters";
diff --git a/examples/hermes/run.mjs b/examples/hermes/run.mjs
deleted file mode 100644
index 0a3dc7e..0000000
--- a/examples/hermes/run.mjs
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * Runnable Hermes example / smoke test.
- *
- * Drives a mock Hermes host through the real hook names the `hermes` adapter
- * binds (on_session_start → pre_llm_call → post_llm_call → on_session_end) and
- * prints the resulting memory. Under USE_FAKE the extraction subagent is a
- * scripted canonical model (list → save → decline a high-risk secret).
- *
- * USE_FAKE=1 node examples/hermes/run.mjs
- * MEMFLYWHEEL_LLM_API_KEY=... node examples/hermes/run.mjs # real model (tools endpoint)
- */
-
-import { mkdtemp, readFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import {
- createMemFlywheelHarnessRuntime,
- hermesAdapter,
- connect,
-} from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import { createFakeModel } from "../shared/fake-model.mjs";
-import { transcript } from "../shared/transcript.mjs";
-
-function createMockHermesHost() {
- const listeners = new Map();
- return {
- on(event, fn) {
- const set = listeners.get(event) ?? new Set();
- set.add(fn);
- listeners.set(event, set);
- return () => set.delete(fn);
- },
- emit(event, payload) {
- for (const fn of listeners.get(event) ?? []) fn(payload);
- },
- };
-}
-
-const useFake = process.env.USE_FAKE === "1";
-const root = await mkdtemp(path.join(tmpdir(), "memflywheel-hermes-"));
-
-const model = useFake ? createFakeModel() : createOpenAIChatCompletionsModel();
-const { scribe } = useFake
- ? createMemFlywheelHarnessRuntime({ model, root })
- : createMemFlywheelHarnessRuntime({ model, root });
-
-const host = createMockHermesHost();
-const dispose = hermesAdapter.attach(scribe, host);
-
-host.emit("on_session_start", { session_id: "demo" });
-
-let ctxPromise;
-host.emit("pre_llm_call", {
- session_id: "demo",
- respond: (p) => (ctxPromise = p),
-});
-const ctx = await ctxPromise;
-console.log("[prompt-build] enabled:", ctx?.enabled);
-
-// Hermes post_llm_call exposes user_message + assistant_response.
-await scribe.onTurnEnd({ sessionId: "demo", messages: transcript });
-host.emit("on_session_end", { session_id: "demo" });
-
-const index = await readFile(path.join(root, "MEMORY.md"), "utf8").catch(() => "");
-console.log("\n--- MEMORY.md ---\n" + (index || "(empty)"));
-
-const configPath = path.join(root, "hermes-config.json");
-const res = await connect(hermesAdapter, { configPath, apply: true });
-console.log("\n[connect] verify ok:", res.verify?.ok, res.verify?.problems ?? []);
-
-dispose();
-
-if (useFake) {
- if (!/green tea|Preferred drink/.test(index)) {
- console.error("SMOKE FAIL: expected a preference memory to be written");
- process.exit(1);
- }
- if (!/Reply tone/.test(index)) {
- console.error("SMOKE FAIL: expected the subagent's second memory to be written");
- process.exit(1);
- }
- if (new RegExp("sk" + "-ABCDEFabcdef").test(index)) {
- console.error("SMOKE FAIL: a high-risk secret was persisted");
- process.exit(1);
- }
-}
-console.log("\nOK");
diff --git a/examples/learning-loop/README.md b/examples/learning-loop/README.md
deleted file mode 100644
index 2eec42f..0000000
--- a/examples/learning-loop/README.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Learning Loop Example
-
-This example exercises the opt-in learned-skill assembly exposed by
-`createMemFlywheelHarnessRuntime({ model, learnedSkills })`.
-
-`USE_FAKE=1` is deterministic and runs in the default smoke suite. Without
-`USE_FAKE`, the same code path calls a real OpenAI-compatible tool-calling model
-through `MEMFLYWHEEL_LLM_*`.
-
-```text
-turn-end
- -> extraction agent writes a workflow memory through ordinary file tools
- -> skill evolution agent writes a learned skill through stage-bound ordinary file tools
- -> dream agent compresses the workflow memory into a skill cue
- -> next prompt build contains the learned skill route
-```
-
-Required environment:
-
-```sh
-export MEMFLYWHEEL_LLM_ENDPOINT="https://example.com/api/v1"
-export MEMFLYWHEEL_LLM_MODEL="tool-calling-model"
-export MEMFLYWHEEL_LLM_API_KEY="..."
-export MEMFLYWHEEL_LLM_MAX_TOKENS=4096
-node examples/learning-loop/run.mjs
-```
-
-The API key must stay in the environment. Do not write it into repository files.
diff --git a/examples/learning-loop/run.mjs b/examples/learning-loop/run.mjs
deleted file mode 100644
index 6238a6c..0000000
--- a/examples/learning-loop/run.mjs
+++ /dev/null
@@ -1,325 +0,0 @@
-#!/usr/bin/env node
-/**
- * Learned-skill loop regression.
- *
- * USE_FAKE=1 is deterministic and runs in the default example smoke suite.
- * Without USE_FAKE, the script calls a real OpenAI-compatible model through the
- * @memflywheel/model mapper and fails if the model does not perform the required
- * tool calls.
- */
-
-import assert from "node:assert/strict";
-import { mkdtemp, readFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-
-import { scanMemoryFiles, serializeMemoryFile } from "@memflywheel/core";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import { createMemFlywheelHarnessRuntime } from "@iflytekopensource/adapters";
-
-const TARGET_SKILL = "memflywheel-learned-release-review";
-const MEMORY_PATH = "workflow/release-prep-workflow.md";
-const VALID_SKILL = `---
-name: memflywheel-learned-release-review
-display_name: Release Review
-description: Captures a repeatable release preparation review.
----
-
-## Use Cases
-
-- Use when preparing a MemFlywheel package or repository release.
-
-## Procedure
-
-1. Inspect package metadata, package files, and publish configuration.
-2. Inspect README, SECURITY, SUPPORT, CHANGELOG, and examples for release consistency.
-3. Run the repository CI command and package dry-run.
-4. Scan for old names, private paths, credentials, and AI-signature footers.
-5. Summarize release blockers before opening a pull request.
-
-## Guardrails
-
-- Do not publish when secrets, private paths, or old project names are present.
-- Keep release notes concise and evidence-based.
-`;
-
-function requireEnv(name) {
- const value = process.env[name];
- if (!value || !value.trim()) {
- throw new Error(`missing ${name}`);
- }
- return value.trim();
-}
-
-function toolCall(id, name, args) {
- return { id, name, input: args };
-}
-
-function writeMemoryArgs(input) {
- return {
- filePath: `${input.type}/${input.filename}`,
- content: serializeMemoryFile(input),
- };
-}
-
-function createFakeLearningModel() {
- let extractionStep = 0;
- let skillStep = 0;
- let dreamStep = 0;
- return {
- async complete(req) {
- const toolNames = new Set(req.tools.map((tool) => tool.name));
- const system = req.messages.find((message) => message.role === "system")?.content ?? "";
-
- if (system.includes("memory extraction engine")) {
- extractionStep += 1;
- if (extractionStep === 1) {
- return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [
- toolCall(
- "memory-save",
- "write",
- writeMemoryArgs({
- type: "workflow",
- filename: "release-prep-workflow.md",
- name: "release prep workflow",
- description:
- "Reusable release preparation procedure that should become a learned skill.",
- body: [
- "Step 1: inspect package metadata, package files, and publish configuration.",
- "Step 2: inspect README, SECURITY, SUPPORT, CHANGELOG, and examples for release consistency.",
- "Step 3: run the repository CI command and package dry-run.",
- "Step 4: scan for old names, private paths, credentials, and AI-signature footers.",
- "Step 5: summarize release blockers before opening a pull request.",
- ].join("\n"),
- }),
- ),
- ],
- },
- finishReason: "tool-calls",
- };
- }
- return { message: { role: "assistant", content: "done" }, finishReason: "stop" };
- }
-
- if (
- system.includes("learned-skill evolution agent") ||
- system.includes("learned-skill regression")
- ) {
- skillStep += 1;
- if (skillStep === 1) {
- return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [
- toolCall("skill-write", "write", {
- filePath: `${TARGET_SKILL}/SKILL.md`,
- content: VALID_SKILL,
- }),
- ],
- },
- finishReason: "tool-calls",
- };
- }
- return { message: { role: "assistant", content: "done" }, finishReason: "stop" };
- }
-
- if (system.includes("consolidation engine")) {
- dreamStep += 1;
- if (dreamStep === 1) {
- return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [toolCall("memory-read", "read", { filePath: MEMORY_PATH })],
- },
- finishReason: "tool-calls",
- };
- }
- if (dreamStep === 2) {
- return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [
- toolCall("memory-update", "edit", {
- filePath: MEMORY_PATH,
- oldString: [
- "Step 1: inspect package metadata, package files, and publish configuration.",
- "Step 2: inspect README, SECURITY, SUPPORT, CHANGELOG, and examples for release consistency.",
- "Step 3: run the repository CI command and package dry-run.",
- "Step 4: scan for old names, private paths, credentials, and AI-signature footers.",
- "Step 5: summarize release blockers before opening a pull request.",
- ].join("\n"),
- newString: `Release prep workflow is now handled by ${TARGET_SKILL}. Use that learned skill when release readiness comes up.`,
- }),
- ],
- },
- finishReason: "tool-calls",
- };
- }
- return { message: { role: "assistant", content: "done" }, finishReason: "stop" };
- }
-
- throw new Error(`unexpected tool set: ${[...toolNames].join(", ")}`);
- },
- };
-}
-
-const skillEvolutionSystemPrompt = `You are running a MemFlywheel real learned-skill regression. You must create exactly one learned skill named ${TARGET_SKILL}.
-
-# Required behavior
-
-1. Use write with filePath="${TARGET_SKILL}/SKILL.md".
-2. The SKILL.md file must use strict frontmatter with exactly name, display_name, description.
-3. The SKILL.md body must include ## Use Cases, ## Procedure, and ## Guardrails.
-4. The Procedure section must use contiguous numbered steps starting at 1.
-5. After writing SKILL.md, stop. The system derives the skill coordination from the staged file diff.
-
-# Tool-call format
-
-Every tool call argument must be a strict JSON object accepted by the provided
-function schema. Do not put Markdown, code fences, comments, YAML, trailing
-commas, or explanatory text inside the function arguments.
-
-For write, filePath is relative to the staged learned-skills root. Therefore
-the only correct SKILL.md write is:
-{"filePath":"${TARGET_SKILL}/SKILL.md","content":"..."}.
-
-Do not write any other learned skill. Do not call noop.`;
-
-async function main() {
- const useFake = process.env.USE_FAKE === "1";
- const endpoint = useFake ? "fake" : requireEnv("MEMFLYWHEEL_LLM_ENDPOINT");
- const model = useFake ? "fake-learning-loop" : requireEnv("MEMFLYWHEEL_LLM_MODEL");
- const root = process.env.MEMFLYWHEEL_EXAMPLE_ROOT
- ? path.resolve(process.env.MEMFLYWHEEL_EXAMPLE_ROOT)
- : await mkdtemp(path.join(tmpdir(), "memflywheel-real-loop-"));
- const memoryRoot = path.join(root, "memory");
- const skillsRoot = path.join(root, "skills");
- const checkpointRoot = path.join(root, ".skill-checkpoints");
-
- const modelCompletion = useFake
- ? createFakeLearningModel()
- : createOpenAIChatCompletionsModel({
- endpoint,
- model,
- maxTokens: Number.parseInt(process.env.MEMFLYWHEEL_LLM_MAX_TOKENS ?? "4096", 10),
- });
- const instrumentedModel = {
- async complete(req) {
- const response = await modelCompletion.complete(req);
- if (process.env.MEMFLYWHEEL_DEBUG_TOOL_ARGS === "1") {
- for (const call of response.message.toolCalls ?? []) {
- if (call.name === "write" && String(call.input?.filePath ?? "").includes(TARGET_SKILL)) {
- console.error(`[debug] skill write input: ${JSON.stringify(call.input)}`);
- }
- }
- }
- return response;
- },
- };
- const { scribe, sdk } = createMemFlywheelHarnessRuntime({
- root: memoryRoot,
- model: instrumentedModel,
- learnedSkills: {
- skillsRoot,
- checkpointRoot,
- systemPrompt: skillEvolutionSystemPrompt,
- maxSteps: 8,
- reviewPacket: ({ lastExtraction, session }) => ({
- goal: `Create ${TARGET_SKILL} from the release prep workflow memory.`,
- requiredTargetSkill: TARGET_SKILL,
- memoryTopic: "release prep workflow",
- lastExtraction,
- messages: session.messages,
- }),
- toolTrajectory: ({ session }) =>
- session.messages.flatMap((message) =>
- (message.toolCalls ?? []).map((call) => ({
- name: call.name,
- input: call.input,
- output: call.output,
- })),
- ),
- artifactPaths: () => ["README.md", "docs/architecture.md", "packages/sdk/src/index.ts"],
- qualitySignals: () => ({
- repeatedWorkflow: true,
- shouldBecomeSkill: true,
- requiredTargetSkill: TARGET_SKILL,
- }),
- },
- learningLoop: {
- enabled: true,
- source: "local",
- skillLearningEnabled: true,
- gate: { minDoneTurns: 1, cooldownTurns: 0, minToolCalls: 1 },
- },
- });
-
- await scribe.onSessionStart({ sessionId: "real-loop" });
- const result = await scribe.onTurnEnd({
- sessionId: "real-loop",
- messages: [
- {
- role: "user",
- text: "We repeated the release prep workflow again; turn it into a reusable learned skill.",
- },
- {
- role: "assistant",
- text: "I ran the release prep checks.",
- toolCalls: [
- { name: "pnpm", input: { command: "pnpm run ci" }, output: "ok" },
- { name: "secret-scan", input: { command: "rg secret" }, output: "ok" },
- ],
- },
- ],
- });
-
- assert.equal(result.learningLoop?.extraction.ran, true, "extraction ran");
- assert.equal(result.learningLoop?.skillEvolution.ran, true, "skill evolution ran");
- assert.equal(result.learningLoop?.dream.ran, true, "dream ran");
-
- const skillFile = await readFile(path.join(skillsRoot, TARGET_SKILL, "SKILL.md"), "utf8");
- assert.match(skillFile, new RegExp(`name: ${TARGET_SKILL}`));
- assert.match(skillFile, /## Procedure/);
-
- const workflowEntries = (await scanMemoryFiles(memoryRoot)).filter(
- (entry) => entry.type === "workflow",
- );
- assert.equal(workflowEntries.length, 1, "one workflow memory exists");
- const memoryPath = workflowEntries[0].relativePath;
- const memory = await readFile(path.join(memoryRoot, memoryPath), "utf8");
- assert.match(memory, new RegExp(TARGET_SKILL));
- assert.doesNotMatch(memory, /Step 1:/);
- assert.doesNotMatch(memory, /Step 5:/);
-
- const prompt = await scribe.onPromptBuild({ sessionId: "real-loop" });
- assert.match(prompt.preludePrompt, new RegExp(TARGET_SKILL));
-
- console.log(
- JSON.stringify(
- {
- ok: true,
- mode: useFake ? "fake" : "real",
- root,
- model,
- memoryPath,
- targetSkill: TARGET_SKILL,
- skillBytes: skillFile.length,
- memoryBody: memory.body,
- },
- null,
- 2,
- ),
- );
-}
-
-main().catch(async (error) => {
- console.error(error instanceof Error ? error.stack : error);
- process.exitCode = 1;
-});
diff --git a/examples/openclaw/README.md b/examples/openclaw/README.md
index 3ab2d62..b3ff8c2 100644
--- a/examples/openclaw/README.md
+++ b/examples/openclaw/README.md
@@ -1,40 +1,5 @@
-# OpenClaw integration example (recall-only until native model port exists)
+# OpenClaw integration
-OpenClaw's plugin API lets a plugin read prompt/messages via hooks and inject
-context, but does **not** expose a direct model-call interface — a plugin cannot
-drive inference itself. So:
+The plugin occupies OpenClaw's memory slot and resolves the active agent model through `prepareSimpleCompletionModelForAgent`. Its `completeWithPreparedSimpleCompletionModel` transport is wrapped as a pi-ai stream; Extraction, Dream, and Skill Evolution all run in the shared Pi Agent Core runner.
-- **Recall + injection are first-class**: the plugin claims the memory slot via
- `registerMemoryCapability` and returns `prependContext` on prompt build.
-- **Extraction / dream / skill evolution do not run natively** until OpenClaw
- exposes an in-process canonical model port or an explicit sidecar. MemFlywheel
- does not parse text as fake tool calls.
-
-## Lifecycle mapping
-
-| OpenClaw hook | scribe hook | what the adapter does |
-| -------------------- | ---------------- | ---------------------------------------------- |
-| `before_agent_start` | `onSessionStart` | register capability + ensure dir |
-| `context:inject` | `onPromptBuild` | return `prependContext = scribe.preludePrompt` |
-| `agent_end` | `onTurnEnd` | no-op in explicit recall-only mode |
-| `idle:watch` | `onIdle` | gate-checked dream |
-
-## Files
-
-- `plugin.mjs` — `register(api)`: registerMemoryCapability + bind hooks +
- `createMemFlywheelHarnessRuntime({ mode: "recall-only" })`.
-- `run.mjs` — a mock OpenClaw host driving the hooks + `connect` into
- `~/.openclaw/openclaw.json` (a temp file in the example).
-
-## Install
-
-```bash
-node -e "import('@iflytekopensource/adapters').then(m => m.connect(m.openclawAdapter, { apply: true }))"
-```
-
-## Run the smoke test
-
-```bash
-pnpm -r build
-USE_FAKE=1 node examples/openclaw/run.mjs
-```
+No separate write-side model key or endpoint is configured.
diff --git a/examples/package.json b/examples/package.json
index 7302349..ef2868e 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -5,12 +5,11 @@
"type": "module",
"description": "Runnable minimal host integration examples for MemFlywheel.",
"scripts": {
- "smoke": "USE_FAKE=1 node pi/run.mjs && USE_FAKE=1 node hermes/run.mjs && USE_FAKE=1 node openclaw/run.mjs && USE_FAKE=1 node learning-loop/run.mjs"
+ "smoke": "node --check pi/extension.mjs && node --check hermes/plugin-register.mjs && node --check openclaw/plugin.mjs"
},
"dependencies": {
"@memflywheel/core": "workspace:*",
"@iflytekopensource/adapters": "workspace:*",
- "@memflywheel/model": "workspace:*",
"@memflywheel/skills": "workspace:*",
"@memflywheel/sdk": "workspace:*"
}
diff --git a/examples/pi/README.md b/examples/pi/README.md
index b7cc72e..e0f60fb 100644
--- a/examples/pi/README.md
+++ b/examples/pi/README.md
@@ -1,19 +1,4 @@
-# Pi integration example
-
-Real integration: a Pi `.js`/`.mjs` extension wraps Pi's per-session auxiliary
-tool-calling completion into `createPiHarnessPort(pi)`, builds the scribe with
-`createMemFlywheelHarnessRuntime({ port })`, and attaches the `pi` adapter so the
-lifecycle fires on Pi's events. Extraction is a tool-calling subagent loop that
-writes memory files directly.
-
-## Files
-
-- `extension.mjs` — the Pi extension entry (`export default function(pi)`): wraps
- Pi as a `HostHarnessPort`, builds the scribe, `piAdapter.attach`.
-- `run.mjs` — a mock Pi host driving session:ensure → turn:build → agent_end →
- learning:idle, printing `MEMORY.md`, then `connect` (install + verify).
-
-## Install into a real Pi
+# Pi integration
Install the published Pi package:
@@ -21,27 +6,6 @@ Install the published Pi package:
pi install npm:@iflytekopensource/adapters
```
-Pi loads the extension declared by `@iflytekopensource/adapters`:
-
-```json
-{
- "keywords": ["pi-package"],
- "pi": {
- "extensions": ["./pi-extension/index.mjs"]
- }
-}
-```
-
-`extension.mjs` in this directory is the readable source equivalent of the
-published package entrypoint.
-
-## Run the smoke test
-
-```bash
-pnpm -r build
-USE_FAKE=1 node examples/pi/run.mjs
-```
+The extension passes Pi's active `ctx.model`, native `streamSimple`, host auth, thinking level, and isolated background session id into the shared Pi Agent Core runner. Changing the model in Pi changes the memory agent model on the next run; no `MEMFLYWHEEL_LLM_*` variables are read.
-Real model: a real Pi process should expose `completeSimple` (or equivalent) to
-`createPiHarnessPort(pi)`. The standalone smoke can use `MEMFLYWHEEL_LLM_*` through
-the OpenAI-compatible mapper when Pi's own process is not in scope.
+`extension.mjs` is the readable source equivalent of the published package entrypoint.
diff --git a/examples/pi/e2e-boundaries.mjs b/examples/pi/e2e-boundaries.mjs
deleted file mode 100644
index d03f371..0000000
--- a/examples/pi/e2e-boundaries.mjs
+++ /dev/null
@@ -1,583 +0,0 @@
-/**
- * MemFlywheel × Pi — 10 boundary E2E scenarios (real model) + request/response capture proxy.
- *
- * Hard pass/fail only (no warn). A reverse proxy in front of the model captures BOTH the
- * raw request and the raw response for every upstream call, classifies it (extraction /
- * skill / dream), redacts secrets, writes a JSONL transcript to a temp file, and prints a
- * per-kind digest so you can read exactly what the model sees and returns — for tuning.
- *
- * SECURITY: key from env only; never hardcoded; redacted out of the capture transcript.
- *
- * export MEMFLYWHEEL_LLM_API_KEY=sk-...
- * export MEMFLYWHEEL_LLM_ENDPOINT=https://api.deepseek.com/v1
- * export MEMFLYWHEEL_LLM_MODEL=deepseek-v4-flash
- * node examples/pi/e2e-boundaries.mjs
- */
-
-import { mkdtemp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
-import { createServer } from "node:http";
-import https from "node:https";
-import { tmpdir } from "node:os";
-import path from "node:path";
-
-import {
- createMemFlywheelHarnessRuntime,
- createPiHarnessPort,
- classifyHostCapabilities,
-} from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import { validateLearnedSkillPackage, createLearnedSkillStore } from "@memflywheel/skills";
-
-// ───────────────────────────── config ──────────────────────────────────────
-
-const ENDPOINT = process.env.MEMFLYWHEEL_LLM_ENDPOINT ?? "https://api.deepseek.com/v1";
-const MODEL = process.env.MEMFLYWHEEL_LLM_MODEL ?? "deepseek-v4-flash";
-const API_KEY =
- process.env.MEMFLYWHEEL_LLM_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY;
-const HAVE_KEY = Boolean(API_KEY);
-let ACTIVE_ENDPOINT = ENDPOINT;
-let ACTIVE_API_KEY = API_KEY;
-
-function buildModel() {
- return createOpenAIChatCompletionsModel({
- endpoint: ACTIVE_ENDPOINT,
- apiKey: ACTIVE_API_KEY,
- model: MODEL,
- maxTokens: 4096,
- temperature: 0,
- });
-}
-
-// ─────────────────────────── results ───────────────────────────────────────
-
-const results = [];
-function record(name, ok, detail = "") {
- results.push({ name, ok });
- console.log(` ${ok ? "✅" : "❌"} ${name}${ok ? "" : ` — ${detail || "failed"}`}`);
-}
-const check = (name, cond, detail = "") => record(name, Boolean(cond), detail);
-const banner = (t) => console.log(`\n── ${t}`);
-const passed = () => results.filter((r) => r.ok).length;
-const failed = () => results.filter((r) => !r.ok).length;
-
-// ─────────────────────── capture proxy (req + resp) ─────────────────────────
-
-const capture = []; // { kind, request, response }
-
-function redact(value) {
- if (typeof value === "string") {
- let out = value;
- if (API_KEY) out = out.split(API_KEY).join("[KEY]");
- return out
- .replace(/Bearer\s+sk-[A-Za-z0-9._-]+/g, "Bearer [REDACTED]")
- .replace(/sk-[A-Za-z0-9._-]{12,}/g, "[SECRET]");
- }
- if (Array.isArray(value)) return value.map(redact);
- if (value && typeof value === "object") {
- const out = {};
- for (const [k, v] of Object.entries(value))
- out[k] = /authorization|api[_-]?key|token|secret/i.test(k) ? "[REDACTED]" : redact(v);
- return out;
- }
- return value;
-}
-
-function classifyKind(body) {
- const system =
- (Array.isArray(body?.messages) ? body.messages : []).find((m) => m?.role === "system")
- ?.content ?? "";
- if (/long-term memory extraction engine/i.test(system)) return "extraction";
- if (/learned-skill evolution agent/i.test(system)) return "skill";
- if (/Dream reviews the WHOLE store|dream/i.test(system)) return "dream";
- return "other";
-}
-
-function summarizeRequest(body) {
- const messages = Array.isArray(body?.messages) ? body.messages : [];
- const tools = Array.isArray(body?.tools) ? body.tools : [];
- return {
- model: body?.model,
- roles: messages.map((m) => m?.role),
- systemHead: redact(
- String(messages.find((m) => m?.role === "system")?.content ?? "").slice(0, 600),
- ),
- lastUserHead: redact(
- String([...messages].reverse().find((m) => m?.role === "user")?.content ?? "").slice(0, 400),
- ),
- tools: tools.map((t) => ({
- name: t?.function?.name ?? t?.name,
- params: Object.keys((t?.function?.parameters ?? {}).properties ?? {}),
- })),
- };
-}
-
-function summarizeResponse(json) {
- const choice = json?.choices?.[0]?.message ?? {};
- return {
- finishReason: json?.choices?.[0]?.finish_reason,
- content: redact(
- typeof choice.content === "string" ? choice.content.slice(0, 600) : choice.content,
- ),
- toolCalls: (choice.tool_calls ?? []).map((c) => ({
- name: c?.function?.name,
- args: redact(String(c?.function?.arguments ?? "").slice(0, 300)),
- })),
- usage: json?.usage,
- };
-}
-
-async function startProxy() {
- const upstream = new URL(ENDPOINT.replace(/\/+$/, ""));
- const server = createServer((req, res) => {
- const chunks = [];
- req.on("data", (c) => chunks.push(c));
- req.on("end", () => {
- const raw = Buffer.concat(chunks).toString("utf8");
- let body;
- try {
- body = raw ? JSON.parse(raw) : undefined;
- } catch {
- body = undefined;
- }
- const suffix = (req.url ?? "").replace(/^\/v1(?=\/|$)/, "");
- const upstreamReq = https.request(
- {
- hostname: upstream.hostname,
- port: 443,
- path: `${upstream.pathname.replace(/\/+$/, "")}${suffix}`,
- method: req.method,
- headers: { ...req.headers, host: upstream.hostname, authorization: `Bearer ${API_KEY}` },
- },
- (up) => {
- const respChunks = [];
- up.on("data", (c) => respChunks.push(c));
- up.on("end", () => {
- const respRaw = Buffer.concat(respChunks).toString("utf8");
- let respJson;
- try {
- respJson = JSON.parse(respRaw);
- } catch {
- respJson = undefined;
- }
- capture.push({
- kind: classifyKind(body),
- request: summarizeRequest(body),
- response: respJson
- ? summarizeResponse(respJson)
- : { raw: redact(respRaw.slice(0, 600)) },
- });
- res.writeHead(up.statusCode ?? 502, up.headers);
- res.end(Buffer.concat(respChunks));
- });
- },
- );
- upstreamReq.on("error", (e) => {
- res.writeHead(502);
- res.end(JSON.stringify({ error: e.message }));
- });
- upstreamReq.end(raw);
- });
- });
- await new Promise((r) => server.listen(0, "127.0.0.1", r));
- ACTIVE_ENDPOINT = `http://127.0.0.1:${server.address().port}/v1`;
- ACTIVE_API_KEY = "memflywheel-local-proxy-token";
- return server;
-}
-
-// ──────────────────────────── fs helpers ───────────────────────────────────
-
-async function walk(dir, rel = "") {
- const out = [];
- let ents;
- try {
- ents = await readdir(dir, { withFileTypes: true });
- } catch {
- return out;
- }
- for (const e of ents) {
- const r = rel ? `${rel}/${e.name}` : e.name;
- if (e.isDirectory()) out.push(...(await walk(path.join(dir, e.name), r)));
- else out.push({ rel, abs: path.join(dir, e.name), name: r });
- }
- return out;
-}
-const readSafe = (p) => readFile(p, "utf8").catch(() => "");
-async function dumpRoot(root) {
- const files = await walk(root);
- return { files, index: await readSafe(path.join(root, "MEMORY.md")) };
-}
-const tmp = (p) => mkdtemp(path.join(tmpdir(), p));
-
-// ──────────────────────────── transcripts ──────────────────────────────────
-
-const VALID_TYPES = ["identity", "preference", "style", "workflow", "context", "ambient"];
-
-const facts = [
- { role: "user", text: "我叫 Kai,后端工程师,咖啡只喝美式,回复请简洁,项目部署在 ap-singapore。" },
- { role: "assistant", text: "记下了 Kai:后端、美式、简洁、ap-singapore。" },
-];
-const secret = [
- {
- role: "user",
- text:
- "存下发布习惯:用 GitHub Actions。token 是 " + "sk-" + "BOUNDARY0123456789ABCD(别存这个)。",
- },
- { role: "assistant", text: "好的,习惯已记,凭据不保存。" },
-];
-const chitchat = [
- { role: "user", text: "今天天气不错,随便聊聊。" },
- { role: "assistant", text: "是啊,适合散步。" },
-];
-const releaseProcedure = [
- { role: "user", text: "把这个 monorepo 发个版,记成以后能照做的标准流程。" },
- {
- role: "assistant",
- text: "开始,先构建。",
- toolCalls: [{ name: "bash", input: { command: "pnpm -r build" }, output: "8 packages built" }],
- },
- {
- role: "assistant",
- text: "构建过,跑测试。",
- toolCalls: [{ name: "bash", input: { command: "pnpm -r test" }, output: "240 passed" }],
- },
- {
- role: "assistant",
- text: "改 changelog。",
- toolCalls: [
- { name: "edit", input: { filePath: "CHANGELOG.md", text: "## 0.2.0" }, output: "edited" },
- { name: "bash", input: { command: "git add CHANGELOG.md" }, output: "staged" },
- ],
- },
- {
- role: "assistant",
- text: "发布并打 tag。",
- toolCalls: [
- { name: "bash", input: { command: "pnpm -r publish" }, output: "published" },
- { name: "bash", input: { command: "git tag v0.2.0 && git push --tags" }, output: "tagged" },
- ],
- },
- { role: "user", text: "以后每次发版都这样走。" },
- { role: "assistant", text: "已确认:构建→测试→改 changelog→发布→打 tag,固定流程。" },
-];
-
-function skillRuntime(root, skillsRoot, ckptRoot, gate) {
- return createMemFlywheelHarnessRuntime({
- model: buildModel(),
- root,
- learnedSkills: { skillsRoot, checkpointRoot: ckptRoot },
- learningLoop: { gate },
- });
-}
-
-// ═══════════════════════════════ scenarios ═════════════════════════════════
-
-async function b1_recallOnly() {
- banner("B1 · recall-only 模式:能召回、不需要模型、不抽取");
- const root = await tmp("ms-b1-");
- const { scribe, mode } = createMemFlywheelHarnessRuntime({ root, mode: "recall-only" });
- check("B1 mode === recall-only", mode === "recall-only", `mode=${mode}`);
- await scribe.onSessionStart({ sessionId: "b1" });
- const ctx = await scribe.onPromptBuild({ sessionId: "b1" });
- check("B1 prompt-build returns a context", typeof ctx?.enabled === "boolean");
-}
-
-async function b2_failFast() {
- banner("B2 · fail-fast:无 model 且非 recall-only → 抛错,不静默降级");
- const root = await tmp("ms-b2-");
- let threw = false;
- try {
- createMemFlywheelHarnessRuntime({ root });
- } catch (e) {
- threw = /canonical model|extraction agent/i.test(e?.message ?? "");
- }
- check("B2 throws without a model", threw);
-}
-
-async function b3_capability() {
- banner("B3 · 能力分级:Pi port → skill-loop,且具备 tool-trajectory");
- const stubPi = { on: () => () => {}, off: () => {} };
- const completeSimple = async () => ({ role: "assistant", content: [{ type: "text", text: "" }] });
- const port = createPiHarnessPort(stubPi, { completeSimple });
- check("B3 classify === skill-loop", classifyHostCapabilities(port.capabilities) === "skill-loop");
- check("B3 has tool-trajectory capability", port.capabilities.has("tool-trajectory"));
-}
-
-async function b4_extraction() {
- banner("B4 · turn-end 抽取:写出 typed 文件 + 合法 frontmatter.type + MEMORY.md 同步");
- const root = await tmp("ms-b4-");
- const { scribe } = createMemFlywheelHarnessRuntime({ model: buildModel(), root });
- await scribe.onSessionStart({ sessionId: "b4" });
- const turn = await scribe.onTurnEnd({ sessionId: "b4", messages: facts });
- check("B4 extraction completed", turn?.result === "completed", `result=${turn?.result}`);
- const dump = await dumpRoot(root);
- const mem = dump.files.filter((f) => f.name.endsWith(".md") && f.name !== "MEMORY.md");
- check("B4 a memory file written", mem.length > 0, dump.files.map((f) => f.name).join(","));
- let typedOk = mem.length > 0;
- for (const f of mem) {
- const top = f.name.split("/")[0];
- const t = (await readSafe(f.abs)).match(/^type:\s*(\S+)/m)?.[1];
- if (!VALID_TYPES.includes(top) || !VALID_TYPES.includes(t)) typedOk = false;
- }
- check("B4 every file in a valid typed dir with valid type", typedOk);
- check("B4 MEMORY.md non-empty", dump.index.trim().length > 0);
- return root;
-}
-
-async function b5_recall(root) {
- banner("B5 · 跨轮召回:上轮记忆作为索引注入下轮 prompt(不是正文)");
- const { scribe } = createMemFlywheelHarnessRuntime({ model: buildModel(), root });
- const ctx = await scribe.onPromptBuild({ sessionId: "b4" });
- const text = `${ctx.systemPrompt ?? ""}\n${ctx.preludePrompt ?? ""}`;
- const signals = [/美式|coffee|偏好/i, /简洁|style|风格/i, /Kai|身份/i, /singapore|部署/i].filter(
- (re) => re.test(text),
- ).length;
- check("B5 prior memory recalled (>=2 signals)", signals >= 2, `signals=${signals}`);
- check("B5 prelude is an index, not raw bodies", /可用记忆|Available memories|- \[/i.test(text));
-}
-
-async function b6_privacy() {
- banner("B6 · 隐私:transcript 里的 secret 永不落盘");
- const root = await tmp("ms-b6-");
- const { scribe } = createMemFlywheelHarnessRuntime({ model: buildModel(), root });
- await scribe.onSessionStart({ sessionId: "b6" });
- await scribe.onTurnEnd({ sessionId: "b6", messages: secret });
- const dump = await dumpRoot(root);
- const all = (await Promise.all(dump.files.map((f) => readSafe(f.abs)))).join("\n") + dump.index;
- check("B6 secret NOT persisted to disk", !new RegExp("sk-" + "BOUNDARY0123456789").test(all));
-}
-
-async function b7_dream() {
- banner("B7 · idle → dream:运行、索引仍可用、记忆未丢");
- const root = await tmp("ms-b7-");
- const { scribe } = createMemFlywheelHarnessRuntime({ model: buildModel(), root });
- await scribe.onSessionStart({ sessionId: "b7" });
- await scribe.onTurnEnd({ sessionId: "b7", messages: facts });
- const before = (await dumpRoot(root)).files.filter(
- (f) => f.name.endsWith(".md") && f.name !== "MEMORY.md",
- ).length;
- let err = null;
- try {
- await scribe.onIdle({ force: true });
- } catch (e) {
- err = e?.message ?? String(e);
- }
- const after = await dumpRoot(root);
- const afterN = after.files.filter((f) => f.name.endsWith(".md") && f.name !== "MEMORY.md").length;
- check("B7 dream ran without throwing", err === null, err ?? "");
- check(
- "B7 index parseable, memories preserved",
- typeof after.index === "string" && afterN >= 1 && afterN <= before,
- `before=${before} after=${afterN}`,
- );
-}
-
-async function b8_skillCreate() {
- banner("B8 · 技能闭环:富轨迹 → 合法技能包 + 召回 + decision=create + 不泄漏 + dream 联动");
- const MAX = 4;
- let ok = null,
- lastErr = "";
- for (let i = 1; i <= MAX && !ok; i += 1) {
- const root = await tmp("ms-b8-"),
- skillsRoot = await tmp("ms-b8-s-"),
- ckpt = await tmp("ms-b8-c-");
- await mkdir(skillsRoot, { recursive: true });
- const { scribe } = skillRuntime(root, skillsRoot, ckpt, {
- minDoneTurns: 1,
- cooldownTurns: 0,
- minToolCalls: 6,
- });
- await scribe.onSessionStart({ sessionId: "b8" });
- try {
- const t = await scribe.onTurnEnd({ sessionId: "b8", messages: releaseProcedure });
- const slugs = (await readdir(skillsRoot, { withFileTypes: true }))
- .filter((d) => d.isDirectory())
- .map((d) => d.name);
- if (slugs.length === 1) {
- ok = { scribe, skillsRoot, slug: slugs[0], loop: t?.learningLoop };
- } else lastErr = `slugs=[${slugs.join(",")}]`;
- } catch (e) {
- lastErr = e?.message ?? String(e);
- }
- if (!ok) console.log(` attempt ${i}: ${lastErr}`);
- }
- check("B8 produced a learned skill (<=4 attempts)", Boolean(ok), lastErr);
- if (!ok) {
- for (const n of [
- "B8 valid package",
- "B8 recalled",
- "B8 decision=create",
- "B8 package hygiene",
- "B8 dream 联动",
- ])
- record(n, false, "no skill");
- return;
- }
- const files = {};
- for (const f of await readdir(path.join(ok.skillsRoot, ok.slug)))
- files[f] = await readSafe(path.join(ok.skillsRoot, ok.slug, f));
- let valid = false,
- ve = "";
- try {
- validateLearnedSkillPackage({ slug: ok.slug.replace(/^memflywheel-learned-/, ""), files });
- valid = true;
- } catch (e) {
- ve = e?.message ?? String(e);
- }
- check("B8 valid package (validateLearnedSkillPackage)", valid, ve);
- const sp = (await ok.scribe.onPromptBuild({ sessionId: "b8" })).skillPreludePrompt ?? "";
- check("B8 recalled in skillPrelude (name + path)", sp.includes(ok.slug) && /path:/i.test(sp));
- check(
- "B8 decision === create",
- ok.loop?.skillEvolution?.value?.coordination?.decision === "create",
- `decision=${ok.loop?.skillEvolution?.value?.coordination?.decision}`,
- );
- check(
- "B8 package has no root hidden metadata files",
- !Object.keys(files).some((f) => f.startsWith(".")),
- );
- check(
- "B8 dream 联动 fired (memory↔skill)",
- ok.loop?.dream?.ran === true,
- `dream=${JSON.stringify(ok.loop?.dream)}`,
- );
-}
-
-async function b9_gateBlocks() {
- banner("B9 · 不触发边界:不够格的轮次不跑技能演化(两道闸)");
- // B9a: 闲聊 → 抽取无可记内容(Skipped) → 前置闸 extraction-not-completed
- {
- const root = await tmp("ms-b9a-"),
- skillsRoot = await tmp("ms-b9a-s-"),
- ckpt = await tmp("ms-b9a-c-");
- await mkdir(skillsRoot, { recursive: true });
- const { scribe } = createMemFlywheelHarnessRuntime({
- model: buildModel(),
- root,
- learnedSkills: { skillsRoot, checkpointRoot: ckpt },
- });
- await scribe.onSessionStart({ sessionId: "b9a" });
- const t = await scribe.onTurnEnd({ sessionId: "b9a", messages: chitchat });
- const reason = t?.learningLoop?.skillEvolution?.reason;
- check(
- "B9a chitchat → skill skipped (extraction-not-completed)",
- t?.learningLoop?.skillEvolution?.ran === false &&
- /extraction-not-completed|min-/.test(reason ?? ""),
- `reason=${reason}`,
- );
- }
- // B9b: 有可记内容但首轮(doneTurns=1 < minDoneTurns=3) → 门控闸 min-done-turns
- {
- const root = await tmp("ms-b9b-"),
- skillsRoot = await tmp("ms-b9b-s-"),
- ckpt = await tmp("ms-b9b-c-");
- await mkdir(skillsRoot, { recursive: true });
- const { scribe } = createMemFlywheelHarnessRuntime({
- model: buildModel(),
- root,
- learnedSkills: { skillsRoot, checkpointRoot: ckpt },
- });
- await scribe.onSessionStart({ sessionId: "b9b" });
- const t = await scribe.onTurnEnd({ sessionId: "b9b", messages: facts });
- const reason = t?.learningLoop?.skillEvolution?.reason;
- check(
- "B9b first meaningful turn → blocked by gate",
- t?.learningLoop?.skillEvolution?.ran === false &&
- /min-done-turns|min-tool-calls|cooldown/.test(reason ?? ""),
- `reason=${reason}`,
- );
- const slugs = (await readdir(skillsRoot, { withFileTypes: true }).catch(() => [])).filter((d) =>
- d.isDirectory(),
- );
- check("B9b no skill produced when gated", slugs.length === 0);
- }
-}
-
-async function b10_sandbox() {
- banner("B10 · 沙箱边界:技能 store 的 bash 拒绝绝对路径(防越界写 skillsRoot)");
- const root = await tmp("ms-b10-");
- const store = createLearnedSkillStore({
- skillsRoot: path.join(root, "skills"),
- checkpointRoot: path.join(root, "ckpt"),
- });
- const checkpoint = await store.createSkillCheckpoint();
- const bash = store.createFileTools(checkpoint).find((t) => t.name === "bash");
- const abs = await bash.handler({ command: `cat > ${root}/skills/x/SKILL.md << 'EOF'\nhi\nEOF` });
- check(
- "B10 bash rejects an absolute path",
- abs.ok === false && /relative paths only|absolute/i.test(abs.text),
- abs.text?.slice(0, 80),
- );
- const rel = await bash.handler({ command: "mkdir -p memflywheel-learned-x" });
- check("B10 bash allows a relative path", rel.ok === true, rel.text?.slice(0, 80));
-}
-
-// ──────────────────────── capture digest ───────────────────────────────────
-
-async function dumpCapture() {
- if (!HAVE_KEY || capture.length === 0) return;
- const file = path.join(await tmp("ms-capture-"), "model-traffic.jsonl");
- await writeFile(file, capture.map((c) => JSON.stringify(c)).join("\n") + "\n", "utf8");
- const byKind = capture.reduce((m, c) => ((m[c.kind] = (m[c.kind] ?? 0) + 1), m), {});
- console.log(`\n── 模型原始流量(已脱敏):${capture.length} 次调用 ${JSON.stringify(byKind)}`);
- console.log(` 全量 JSONL: ${file}`);
- for (const kind of ["extraction", "skill", "dream"]) {
- const sample = capture.find((c) => c.kind === kind);
- if (!sample) continue;
- console.log(`\n ▼ ${kind} 样本`);
- console.log(` req.tools = [${sample.request.tools.map((t) => t.name).join(", ")}]`);
- console.log(
- ` req.system(head) = ${JSON.stringify(sample.request.systemHead.slice(0, 140))}`,
- );
- console.log(
- ` resp.finish = ${sample.response.finishReason}; toolCalls = [${(sample.response.toolCalls ?? []).map((t) => t.name).join(", ")}]`,
- );
- console.log(
- ` resp.content(head) = ${JSON.stringify(String(sample.response.content ?? "").slice(0, 160))}`,
- );
- }
-}
-
-// ──────────────────────────────── main ─────────────────────────────────────
-
-async function main() {
- console.log(
- `MemFlywheel × Pi — 10 BOUNDARY E2E — model=${MODEL} endpoint=${ENDPOINT} key=${HAVE_KEY ? "YES" : "NO"}`,
- );
- let proxy = null;
- if (HAVE_KEY) proxy = await startProxy();
- try {
- await b1_recallOnly();
- await b2_failFast();
- await b3_capability();
- await b10_sandbox();
- if (!HAVE_KEY) {
- for (const n of [
- "B4 extraction",
- "B5 recall",
- "B6 privacy",
- "B7 dream",
- "B8 skill",
- "B9 gate",
- ])
- record(n, false, "no key (set MEMFLYWHEEL_LLM_API_KEY)");
- } else {
- const root = await b4_extraction();
- await b5_recall(root);
- await b6_privacy();
- await b7_dream();
- await b8_skillCreate();
- await b9_gateBlocks();
- }
- } finally {
- if (proxy) await new Promise((r) => proxy.close(r));
- }
- await dumpCapture();
- console.log(`\n ${passed()} pass · ${failed()} fail`);
- if (failed() > 0) for (const r of results.filter((r) => !r.ok)) console.log(` ❌ ${r.name}`);
- process.exit(failed() > 0 ? 1 : 0);
-}
-
-main().catch((e) => {
- console.error("FATAL:", e?.stack ?? e);
- process.exit(2);
-});
diff --git a/examples/pi/e2e-deepseek.mjs b/examples/pi/e2e-deepseek.mjs
deleted file mode 100644
index 8692604..0000000
--- a/examples/pi/e2e-deepseek.mjs
+++ /dev/null
@@ -1,862 +0,0 @@
-/**
- * MemFlywheel × Pi — comprehensive, boundary-focused end-to-end test (real model: DeepSeek).
- *
- * Written to be run, read, and iterated on. Scenarios are grouped:
- * A · 记忆闭环 (memory loop) — needs a real model
- * B · 技能闭环 (skill loop) — needs a real model; signals are TRAJECTORY-DERIVED
- * from captured tool calls
- * C · 边界 (boundaries) — DETERMINISTIC, no model/key required
- * D · 真实 Pi 接入 (PI_REAL=1) — boots a real Pi AgentSession
- *
- * The C (boundary) group runs WITHOUT a key, so this file is useful immediately.
- * A/B run only when a key is present; D runs only with PI_REAL=1 and a key.
- *
- * ─────────────────────────────────────────────────────────────────────────────
- * SECURITY: never hardcode a key. Read it from the env.
- *
- * export MEMFLYWHEEL_LLM_API_KEY=sk-... # your DeepSeek key
- * export MEMFLYWHEEL_LLM_ENDPOINT=https://api.deepseek.com/v1
- * export MEMFLYWHEEL_LLM_MODEL=deepseek-v4-flash
- *
- * node examples/pi/e2e-deepseek.mjs # A+B (if key) + C (always)
- * PI_REAL=1 node examples/pi/e2e-deepseek.mjs # also boot real Pi (D)
- * node examples/pi/e2e-deepseek.mjs # no key → runs only C
- * ─────────────────────────────────────────────────────────────────────────────
- */
-
-import { mkdtemp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
-import { createServer } from "node:http";
-import { tmpdir } from "node:os";
-import path from "node:path";
-
-import {
- createMemFlywheelHarnessRuntime,
- createPiHarnessPort,
- classifyHostCapabilities,
- requireHostCapabilities,
- createCapabilitySet,
-} from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import {
- validateLearnedSkillPackage,
- LearnedSkillValidationError,
- createLearnedSkillStore,
- createLearnedSkillRecallProvider,
- buildLearnedSkillPrelude,
-} from "@memflywheel/skills";
-
-// ───────────────────────────── config ──────────────────────────────────────
-
-const ENDPOINT =
- process.env.MEMFLYWHEEL_LLM_ENDPOINT ??
- process.env.DEEPSEEK_BASE_URL ??
- "https://api.deepseek.com/v1";
-const MODEL =
- process.env.MEMFLYWHEEL_LLM_MODEL ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash";
-const API_KEY =
- process.env.MEMFLYWHEEL_LLM_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY;
-const HAVE_KEY = Boolean(API_KEY);
-const CAPTURE_PROXY = HAVE_KEY && process.env.MEMFLYWHEEL_CAPTURE_PROXY !== "0";
-let ACTIVE_ENDPOINT = ENDPOINT;
-let ACTIVE_API_KEY = API_KEY;
-const proxyLog = [];
-
-function buildModel() {
- return createOpenAIChatCompletionsModel({
- endpoint: ACTIVE_ENDPOINT,
- apiKey: ACTIVE_API_KEY,
- model: MODEL,
- });
-}
-
-function redactString(value) {
- let out = value;
- if (API_KEY) out = out.split(API_KEY).join("[REDACTED_API_KEY]");
- return out
- .replace(/Bearer\s+sk-[A-Za-z0-9._-]+/g, "Bearer [REDACTED]")
- .replace(/sk-[A-Za-z0-9._-]{12,}/g, "[REDACTED_SECRET]");
-}
-
-function redactJson(value) {
- if (typeof value === "string") return redactString(value);
- if (Array.isArray(value)) return value.map(redactJson);
- if (value && typeof value === "object") {
- const out = {};
- for (const [key, child] of Object.entries(value)) {
- out[key] = /authorization|api[_-]?key|token|secret/i.test(key)
- ? "[REDACTED]"
- : redactJson(child);
- }
- return out;
- }
- return value;
-}
-
-function requestBodySummary(body) {
- const messages = Array.isArray(body?.messages) ? body.messages : [];
- const tools = Array.isArray(body?.tools) ? body.tools : [];
- return {
- model: body?.model,
- tool_choice: body?.tool_choice,
- max_tokens: body?.max_tokens,
- temperature: body?.temperature,
- messageRoles: messages.map((m) => m?.role),
- promptHead: messages
- .map((m) => (typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "")))
- .join("\n")
- .slice(0, 1200),
- toolNames: tools.map((tool) => tool?.function?.name ?? tool?.name).filter(Boolean),
- };
-}
-
-async function startCaptureProxy() {
- const upstream = ENDPOINT.replace(/\/+$/, "");
- const server = createServer(async (req, res) => {
- const chunks = [];
- for await (const chunk of req) chunks.push(chunk);
- const raw = Buffer.concat(chunks).toString("utf8");
- let body;
- try {
- body = raw ? JSON.parse(raw) : undefined;
- } catch {
- body = raw;
- }
- const entry = {
- method: req.method,
- url: req.url,
- request: redactJson(body),
- summary: requestBodySummary(body),
- };
- proxyLog.push(entry);
-
- const suffix = (req.url ?? "").replace(/^\/v1(?=\/|$)/, "");
- const target = `${upstream}${suffix}`;
- const headers = { ...req.headers, authorization: `Bearer ${API_KEY}` };
- delete headers.host;
- delete headers["content-length"];
-
- try {
- const upstreamResponse = await fetch(target, {
- method: req.method,
- headers,
- body: req.method === "GET" || req.method === "HEAD" ? undefined : raw,
- });
- entry.status = upstreamResponse.status;
- res.writeHead(
- upstreamResponse.status,
- Object.fromEntries(upstreamResponse.headers.entries()),
- );
- res.end(Buffer.from(await upstreamResponse.arrayBuffer()));
- } catch (error) {
- entry.error = redactString(error?.message ?? String(error));
- res.writeHead(502, { "content-type": "application/json" });
- res.end(JSON.stringify({ error: entry.error }));
- }
- });
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
- const address = server.address();
- ACTIVE_ENDPOINT = `http://127.0.0.1:${address.port}/v1`;
- ACTIVE_API_KEY = "memflywheel-local-proxy-token";
- return {
- url: ACTIVE_ENDPOINT,
- close: () => new Promise((resolve) => server.close(resolve)),
- };
-}
-
-// ──────────────────────────── tiny harness ─────────────────────────────────
-
-const results = [];
-function banner(t) {
- console.log("\n" + "═".repeat(74) + "\n " + t + "\n" + "═".repeat(74));
-}
-function record(name, status, detail = "") {
- results.push({ name, status });
- const icon =
- status === "pass" ? "✅" : status === "warn" ? "⚠️ " : status === "skip" ? "⏭️ " : "❌";
- console.log(` ${icon} ${name}${detail ? " — " + detail : ""}`);
-}
-function check(name, cond, detail = "") {
- record(name, cond ? "pass" : "fail", detail);
- return cond;
-}
-/** Assert a synchronous call throws (optionally matching a message). */
-function expectThrows(name, fn, re) {
- try {
- fn();
- record(name, "fail", "did NOT throw");
- return false;
- } catch (err) {
- const msg = err?.message ?? String(err);
- const ok = !re || re.test(msg);
- record(name, ok ? "pass" : "fail", ok ? "" : `threw but message mismatch: ${msg}`);
- return ok;
- }
-}
-
-async function listFiles(root) {
- const out = [];
- async function walk(dir) {
- let entries;
- try {
- entries = await readdir(dir, { withFileTypes: true });
- } catch {
- return;
- }
- for (const e of entries) {
- if (e.name.startsWith(".")) continue;
- const full = path.join(dir, e.name);
- if (e.isDirectory()) await walk(full);
- else out.push(full);
- }
- }
- await walk(root);
- return out;
-}
-const readSafe = (p) => readFile(p, "utf8").catch(() => "");
-
-async function dumpRoot(label, root) {
- const index = await readSafe(path.join(root, "MEMORY.md"));
- const files = await listFiles(root);
- console.log(`\n ── ${label}: ${root}`);
- console.log(" MEMORY.md:\n" + (index ? index.replace(/^/gm, " ") : " (empty)"));
- console.log(
- ` files (${files.length}): ${files.map((f) => path.relative(root, f)).join(", ") || "(none)"}`,
- );
- return { index, files };
-}
-
-/** A SKILL.md that passes validateLearnedSkillPackage (strict frontmatter + 3 sections). */
-function validSkillMd(slug, displayName, description) {
- return [
- "---",
- `name: memflywheel-learned-${slug}`,
- `display_name: ${displayName}`,
- `description: ${description}`,
- "---",
- "",
- "## Use Cases",
- "- 当用户要执行该流程时。",
- "",
- "## Procedure",
- "1. 第一步。",
- "2. 第二步。",
- "3. 第三步。",
- "",
- "## Guardrails",
- "- 前置条件不满足时不要继续。",
- "",
- ].join("\n");
-}
-
-// ──────────────────── scenario transcripts (adapter messages) ───────────────
-// onTurnEnd accepts: { role, text, toolCalls?: [{ name, input, output }] }
-
-const factsTranscript = [
- {
- role: "user",
- text:
- "记一下我的偏好:我叫 Kai,主力项目是 MemFlywheel。我喝咖啡只喝美式不加糖。" +
- "回复我语气要简洁直接、不要寒暄。常用部署区域是 ap-singapore。",
- },
- { role: "assistant", text: "明白,已记住。" },
-];
-
-const procedureTranscript = [
- {
- role: "user",
- text: "把 MemFlywheel 的发布流程完整跑一遍。这是我的长期发布约定,必须沉淀成以后可复用的发布 skill。",
- },
- {
- role: "assistant",
- text: "执行发布流程。",
- toolCalls: [
- { name: "bash", input: { command: "pnpm -r build" }, output: "build ok" },
- { name: "bash", input: { command: "pnpm -r test" }, output: "242 passed" },
- {
- name: "bash",
- input: { command: "npm publish --access public" },
- output: "+ memflywheel@0.1.0",
- },
- ],
- },
- {
- role: "assistant",
- text:
- "发布完成。标准流程:1) pnpm -r build;2) pnpm -r test 全绿;3) 更新 CHANGELOG.md;" +
- "4) npm publish --access public;5) git tag v 并 push。",
- },
-];
-
-// Same skill, but this turn the publish step FAILS — the failure lives in the
-// tool trajectory (no manual usage channel). Evolution should learn from it.
-const failureTranscript = [
- {
- role: "user",
- text:
- "再按发布流程发一版。记住这个长期发布经验:如果 npm publish 返回 401," +
- "以后发布 skill 必须先做 npm auth token / NPM_TOKEN preflight。",
- },
- {
- role: "assistant",
- text: "按既有发布流程执行。",
- toolCalls: [
- { name: "bash", input: { command: "pnpm -r build" }, output: "build ok" },
- { name: "bash", input: { command: "pnpm -r test" }, output: "242 passed" },
- {
- name: "bash",
- input: { command: "npm publish --access public" },
- output: "npm ERR! code E401\nnpm ERR! 401 Unauthorized - no auth token",
- },
- ],
- },
- { role: "assistant", text: "发布失败:npm 返回 401 未授权(没有 auth token)。" },
-];
-
-const secretTranscript = [
- {
- role: "user",
- text:
- "存一下我的发布习惯:用 GitHub Actions 发布。我的临时 token 是 " +
- "sk-" +
- "ABCDEF0123456789ABCDEF(别存这个)。",
- },
- { role: "assistant", text: "好的,发布习惯已记,敏感凭据不会保存。" },
-];
-
-// ═══════════════════════ GROUP A · 记忆闭环 (real model) ═══════════════════════
-
-async function groupA() {
- const root = await mkdtemp(path.join(tmpdir(), "ms-A-"));
- const sessionId = "A";
-
- banner("A1 · recall-only 模式:能召回、不抽取(无需模型)");
- {
- const { scribe, mode } = createMemFlywheelHarnessRuntime({ root, mode: "recall-only" });
- check("A1 mode is recall-only", mode === "recall-only", `mode=${mode}`);
- await scribe.onSessionStart({ sessionId });
- const ctx = await scribe.onPromptBuild({ sessionId });
- check("A1 prompt-build returns a context", typeof ctx?.enabled === "boolean");
- }
-
- if (!HAVE_KEY) {
- record("A2 extraction", "skip", "no key");
- record("A3 cross-turn recall", "skip", "no key");
- record("A4 dream", "skip", "no key");
- record("A5 privacy (memory)", "skip", "no key");
- return root;
- }
-
- const { scribe } = createMemFlywheelHarnessRuntime({ model: buildModel(), root });
- await scribe.onSessionStart({ sessionId });
-
- banner("A2 · turn-end 抽取写出真实 memory 文件");
- const turn = await scribe.onTurnEnd({ sessionId, messages: factsTranscript });
- console.log(" onTurnEnd:", JSON.stringify(turn).slice(0, 200));
- const after = await dumpRoot("after extraction", root);
- check("A2 MEMORY.md non-empty", after.index.trim().length > 0);
- check(
- "A2 a memory file written",
- after.files.some((f) => !f.endsWith("MEMORY.md")),
- );
-
- banner("A3 · 跨轮召回:上轮记忆注入下轮 prompt");
- const ctx = await scribe.onPromptBuild({ sessionId });
- const recall = `${ctx.systemPrompt ?? ""}\n${ctx.preludePrompt ?? ""}\n${ctx.skillPreludePrompt ?? ""}`;
- console.log(" prelude (head):", (ctx.preludePrompt ?? "").slice(0, 200).replace(/\n/g, " "));
- const recalledSignals = [
- /preference|偏好|coffee|咖啡|美式/i,
- /style|风格|tone|语气|简洁/i,
- /identity|身份|name|Kai/i,
- /context|项目|MemFlywheel|deployment|ap-singapore|singapore/i,
- ].filter((re) => re.test(recall)).length;
- check(
- "A3 prior memory recalled",
- /可用记忆条目|Available memories/i.test(recall) && recalledSignals >= 2,
- );
-
- banner("A4 · idle → dream 整理,索引仍一致");
- let dream;
- try {
- dream = await scribe.onIdle({ force: true });
- } catch (e) {
- dream = `threw: ${e?.message}`;
- }
- console.log(" onIdle(force):", JSON.stringify(dream ?? null).slice(0, 160));
- const d = await dumpRoot("after dream", root);
- check("A4 dream ran, index still readable", typeof d.index === "string");
-
- banner("A5 · 隐私边界:transcript 里的 secret 永不落盘");
- await scribe.onTurnEnd({ sessionId, messages: secretTranscript });
- const dump = await dumpRoot("after secret turn", root);
- const allText = (await Promise.all(dump.files.map(readSafe))).join("\n") + dump.index;
- check("A5 secret NOT persisted", !new RegExp("sk-" + "ABCDEF0123456789").test(allText));
-
- await scribe.onSessionEnd({ sessionId });
- return root;
-}
-
-// ════════════════ GROUP B · 技能闭环(轨迹驱动, real model)════════════════════
-
-async function groupB() {
- if (!HAVE_KEY) {
- for (const n of [
- "B1 skill evolution",
- "B2 skill route recall",
- "B3 trajectory-derived update",
- "B4 memory→cue",
- ])
- record(n, "skip", "no key");
- return null;
- }
- const root = await mkdtemp(path.join(tmpdir(), "ms-B-"));
- const skillsRoot = await mkdtemp(path.join(tmpdir(), "ms-B-skills-"));
- const checkpointRoot = await mkdtemp(path.join(tmpdir(), "ms-B-checkpoints-"));
- await mkdir(skillsRoot, { recursive: true });
-
- const { scribe } = createMemFlywheelHarnessRuntime({
- model: buildModel(),
- root,
- learnedSkills: {
- skillsRoot,
- checkpointRoot,
- reviewPacket: (input) => {
- const recent = JSON.stringify(input.session.messages);
- const failure = /401|Unauthorized|auth token/i.test(recent);
- return {
- goal: failure
- ? "Update the existing release skill with an auth-token preflight learned from the failing trajectory."
- : "Create a reusable release runbook learned skill from this successful release trajectory.",
- requiredDecision: failure ? "update" : "create",
- targetSkill: "memflywheel-learned-release-runbook",
- requiredFiles: ["memflywheel-learned-release-runbook/SKILL.md"],
- lastExtraction: {
- result: input.lastExtraction.result,
- skipped: input.lastExtraction.skipped,
- },
- recentMessages: input.session.messages.slice(-8),
- };
- },
- },
- // Force the gate so a single rich turn triggers evolution in this demo.
- learningLoop: {
- gate: { minDoneTurns: 0, cooldownTurns: 0, minToolCalls: 0 },
- toolCalls: 99,
- turnsSinceLastSkillEvolution: 99,
- },
- });
- const sessionId = "B";
- await scribe.onSessionStart({ sessionId });
-
- banner("B1 · 技能演化:可复用流程 + 工具轨迹 → 生成合法 learned skill 包");
- let t1;
- try {
- t1 = await scribe.onTurnEnd({ sessionId, messages: procedureTranscript });
- } catch (error) {
- record("B1 skill evolution", "fail", error?.message ?? String(error));
- return { root, skillsRoot };
- }
- console.log(" onTurnEnd:", JSON.stringify(t1).slice(0, 400));
- let skillFiles = (await listFiles(skillsRoot)).filter((f) => /SKILL\.md$/i.test(f));
- for (const f of skillFiles)
- console.log(
- `\n ── ${path.relative(skillsRoot, f)}\n` + (await readSafe(f)).replace(/^/gm, " "),
- );
- record(
- "B1 a learned skill package was created",
- skillFiles.length > 0 ? "pass" : "warn",
- skillFiles.length ? "" : "model declined — inspect onTurnEnd",
- );
-
- banner("B2 · 技能路由召回:新技能以 name+path 出现在 skillPrelude");
- const ctx = await scribe.onPromptBuild({ sessionId });
- const sp = ctx.skillPreludePrompt ?? "";
- console.log(" skillPrelude:\n" + (sp ? sp.replace(/^/gm, " ") : " (empty)"));
- record(
- "B2 skill route present (name + path)",
- /## 可用技能/.test(sp) && /path:/.test(sp) ? "pass" : "warn",
- sp ? "" : "depends on B1",
- );
-
- banner("B3 · 轨迹派生失败 → 技能更新(纯看工具轨迹)");
- const before = skillFiles.length ? await readSafe(skillFiles[0]) : "";
- let t2;
- try {
- t2 = await scribe.onTurnEnd({ sessionId, messages: failureTranscript });
- } catch (error) {
- record("B3 trajectory-derived update", "fail", error?.message ?? String(error));
- return { root, skillsRoot };
- }
- console.log(" onTurnEnd:", JSON.stringify(t2).slice(0, 400));
- const after = skillFiles.length ? await readSafe(skillFiles[0]) : "";
- record(
- "B3 evolution reacted to the failing trajectory",
- before && after && before !== after ? "pass" : "warn",
- before === after
- ? "skill unchanged — model may have declined; inspect output"
- : "skill updated",
- );
-
- banner("B4 · memory → routing cue:流程型记忆压成指向技能的 cue(人工核查)");
- const mem = await readSafe(path.join(root, "MEMORY.md"));
- console.log(" MEMORY.md:\n" + (mem ? mem.replace(/^/gm, " ") : " (empty)"));
- // Tolerant: assert memory does NOT duplicate the full numbered procedure verbatim.
- const dupSteps = /1\)\s*pnpm -r build[\s\S]*2\)\s*pnpm -r test[\s\S]*3\)/.test(mem);
- record(
- "B4 memory keeps a cue, not the full procedure",
- dupSteps ? "warn" : "pass",
- dupSteps ? "full steps still in memory — inspect" : "",
- );
-
- await scribe.onSessionEnd({ sessionId });
- return { root, skillsRoot };
-}
-
-// ══════════════════ GROUP C · 边界 (deterministic, no key) ═════════════════════
-
-async function groupC() {
- banner("C1 · 技能不执行:MemFlywheel 只 store/validate/evolve,无 execute/run/spawn");
- {
- const root = await mkdtemp(path.join(tmpdir(), "ms-C1-"));
- const store = createLearnedSkillStore({
- skillsRoot: path.join(root, "skills"),
- checkpointRoot: path.join(root, "checkpoints"),
- });
- const forbidden = ["execute", "run", "spawn", "load", "invoke", "exec"];
- const storeClean = forbidden.every((m) => typeof store[m] !== "function");
- check(
- "C1 store exposes no execution method",
- storeClean,
- `store keys: ${Object.keys(store).join(",")}`,
- );
- const { scribe } = createMemFlywheelHarnessRuntime({ root, mode: "recall-only" });
- const scribeClean = forbidden.every((m) => typeof scribe[m] !== "function");
- check("C1 scribe exposes no execution method", scribeClean);
- }
-
- banner("C2 · 能力分级:classifyHostCapabilities");
- {
- const all = createCapabilitySet([
- "prompt-build",
- "turn-end",
- "session-end",
- "idle",
- "single-tool-completion",
- "agentic-tool-loop",
- "tool-trajectory",
- ]);
- check("C2 full caps → skill-loop", classifyHostCapabilities(all) === "skill-loop");
- check(
- "C2 drop tool-trajectory → memory-loop",
- classifyHostCapabilities(
- createCapabilitySet(["prompt-build", "turn-end", "agentic-tool-loop"]),
- ) === "memory-loop",
- );
- check(
- "C2 only prompt-build → recall-only",
- classifyHostCapabilities(createCapabilitySet(["prompt-build"])) === "recall-only",
- );
- check("C2 empty → none", classifyHostCapabilities(createCapabilitySet([])) === "none");
- const stubPi = { on: () => () => {} };
- const stubModel = { complete: async () => ({ message: { role: "assistant", content: null } }) };
- const port = createPiHarnessPort(stubPi, { model: stubModel });
- check(
- "C2 Pi port classifies as skill-loop",
- classifyHostCapabilities(port.capabilities) === "skill-loop",
- );
- }
-
- banner("C3 · fail-fast:缺 model / 缺能力 → 显式抛错(无静默降级)");
- {
- const root = await mkdtemp(path.join(tmpdir(), "ms-C3-"));
- expectThrows(
- "C3 no model & not recall-only → throws",
- () => createMemFlywheelHarnessRuntime({ root }),
- /canonical model/i,
- );
- let ok = true;
- try {
- createMemFlywheelHarnessRuntime({ root, mode: "recall-only" });
- } catch {
- ok = false;
- }
- check("C3 recall-only constructs without a model", ok);
- expectThrows(
- "C3 requireHostCapabilities throws on missing cap",
- () =>
- requireHostCapabilities("test", createCapabilitySet(["prompt-build"]), [
- "prompt-build",
- "turn-end",
- ]),
- /missing host capabilities/i,
- );
- }
-
- banner("C4 · 校验边界:validateLearnedSkillPackage 接受合法、拒绝非法");
- {
- const valid = {
- slug: "release-runbook",
- files: {
- "SKILL.md": validSkillMd(
- "release-runbook",
- "Release Runbook",
- "Use when publishing MemFlywheel to npm.",
- ),
- },
- };
- let okValid = true;
- try {
- const v = validateLearnedSkillPackage(valid);
- okValid = v.skillName === "memflywheel-learned-release-runbook";
- } catch (e) {
- okValid = false;
- console.log(" unexpected:", e?.message);
- }
- check("C4 valid package passes", okValid);
-
- expectThrows(
- "C4 missing SKILL.md → rejected",
- () => validateLearnedSkillPackage({ slug: "x", files: { "references/a.md": "hi" } }),
- /SKILL\.md/,
- );
- expectThrows("C4 missing required section → rejected", () =>
- validateLearnedSkillPackage({
- slug: "x",
- files: {
- "SKILL.md":
- "---\nname: memflywheel-learned-x\ndisplay_name: X\ndescription: d\n---\n\n## Use Cases\n- a\n",
- },
- }),
- );
- expectThrows("C4 forbidden public name → rejected", () =>
- validateLearnedSkillPackage({
- slug: "y",
- forbiddenPublicNames: ["AcmeInternal"],
- files: {
- "SKILL.md": validSkillMd("y", "Y", "desc").replace(
- "第一步。",
- "参考 AcmeInternal 内部实现。",
- ),
- },
- }),
- );
- check(
- "C4 LearnedSkillValidationError is exported",
- typeof LearnedSkillValidationError === "function",
- );
- }
-
- banner("C6 · skillsRoot 可达性:路由 path 相对 skillsRoot 且可解析到真实文件");
- {
- const root = await mkdtemp(path.join(tmpdir(), "ms-C6-"));
- const skillsRoot = path.join(root, "skills");
- const dir = path.join(skillsRoot, "memflywheel-learned-demo-skill");
- await mkdir(dir, { recursive: true });
- await writeFile(
- path.join(dir, "SKILL.md"),
- validSkillMd("demo-skill", "Demo Skill", "Use when running the demo."),
- );
- const recall = createLearnedSkillRecallProvider({ skillsRoot });
- const packet = await recall({ sessionId: "x" });
- const prelude = buildLearnedSkillPrelude(packet);
- console.log(" prelude:\n" + prelude.replace(/^/gm, " "));
- const m = prelude.match(/path:\s*(\S+)/);
- check("C6 route includes a path", Boolean(m));
- if (m) {
- const abs = path.resolve(skillsRoot, m[1]);
- const exists = await readFile(abs, "utf8")
- .then(() => true)
- .catch(() => false);
- check(
- "C6 path is relative + resolves under skillsRoot to a real file",
- !path.isAbsolute(m[1]) && exists,
- abs,
- );
- }
- }
-}
-
-// ═══════════════════ GROUP D · 真实 Pi 接入 (PI_REAL=1) ════════════════════════
-
-async function groupD() {
- banner("D · 真实 Pi AgentSession(DeepSeek 当 Pi 主模型)+ MemFlywheel 扩展");
- if (!HAVE_KEY) {
- record("D real Pi", "skip", "no key");
- return;
- }
- let pic, pai;
- try {
- pic = await import("@earendil-works/pi-coding-agent");
- pai = await import("@earendil-works/pi-ai");
- } catch (err) {
- record("D pi packages resolvable", "fail", `import failed: ${err?.message ?? err}`);
- console.log(" Install/link Pi packages before running PI_REAL=1.");
- return;
- }
- record("D pi packages resolvable", "pass");
-
- const {
- createAgentSession,
- DefaultResourceLoader,
- AuthStorage,
- ModelRegistry,
- SessionManager,
- SettingsManager,
- } = pic;
- const { completeSimple } = pai;
- const root = await mkdtemp(path.join(tmpdir(), "ms-D-"));
- const agentDir = path.join(root, "agent");
- await mkdir(agentDir, { recursive: true });
-
- const authStorage = AuthStorage.inMemory();
- authStorage.setRuntimeApiKey("deepseek", ACTIVE_API_KEY);
- const modelRegistry = ModelRegistry.create(authStorage, path.join(agentDir, "models.json"));
- const model = {
- id: MODEL,
- name: "DeepSeek",
- api: "openai-completions",
- provider: "deepseek",
- baseUrl: ACTIVE_ENDPOINT,
- reasoning: false,
- input: ["text"],
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 65536,
- };
- check(
- "D DeepSeek model constructed for Pi",
- model.api === "openai-completions" && model.baseUrl === ACTIVE_ENDPOINT,
- );
-
- let injections = 0,
- turnEnds = 0,
- toolEvents = 0;
-
- const extension = (pi) => {
- const originalOn = pi.on.bind(pi);
- pi.on = (event, handler) => {
- const wrapped = async (...args) => {
- if (event === "context") injections += 1;
- if (event === "agent_end") turnEnds += 1;
- return handler(...args);
- };
- return originalOn(event, wrapped);
- };
- const port = createPiHarnessPort(pi, { completeSimple });
- const runtime = createMemFlywheelHarnessRuntime({ port, root });
- pi.on("tool_call", async () => {
- toolEvents += 1;
- });
- pi.on("tool_result", async () => {
- toolEvents += 1;
- });
- return runtime.dispose;
- };
-
- const resourceLoader = new DefaultResourceLoader({
- cwd: root,
- agentDir,
- settingsManager: SettingsManager.create(root, agentDir),
- extensionFactories: [extension],
- });
- await resourceLoader.reload();
-
- const { session } = await createAgentSession({
- cwd: root,
- agentDir,
- model,
- authStorage,
- modelRegistry,
- resourceLoader,
- sessionManager: SessionManager.inMemory(root),
- });
- try {
- await session.prompt(
- "先使用 Pi 原生 bash 工具执行 `pwd`;" +
- "然后记住:我叫 Kai,做 MemFlywheel,喝美式不加糖。最后用一句话给我今天的建议。",
- );
- } catch (err) {
- record("D real turn completed", "fail", `session.prompt threw: ${err?.message ?? err}`);
- } finally {
- session.dispose?.();
- }
- await new Promise((r) => setTimeout(r, 300));
-
- check("D context → recall injected", injections > 0, `injections=${injections}`);
- check("D agent_end → onTurnEnd fired", turnEnds > 0, `turnEnds=${turnEnds}`);
- check("D tool telemetry observed", toolEvents >= 2, `toolEvents=${toolEvents}`);
- const dump = await dumpRoot("after real Pi turn", root);
- check("D memory written from a real Pi turn", dump.index.trim().length > 0);
-}
-
-function verifyProxyCapture() {
- banner("P · 反向代理观测(raw request capture, redacted)");
- check("P requests captured", proxyLog.length > 0, `requests=${proxyLog.length}`);
- const allToolNames = new Set(proxyLog.flatMap((entry) => entry.summary?.toolNames ?? []));
- const promptText = proxyLog.map((entry) => entry.summary?.promptHead ?? "").join("\n");
- const serialized = JSON.stringify(proxyLog);
- check(
- "P extraction prompt observed",
- /memory extraction engine|Recent conversation|Existing memories/i.test(promptText),
- );
- check(
- "P file tools exposed",
- ["read", "write", "edit", "bash", "glob", "grep"].every((name) => allToolNames.has(name)),
- `tools=${[...allToolNames].sort().join(",")}`,
- );
- check("P no API key in captured logs", !API_KEY || !serialized.includes(API_KEY));
- check(
- "P no bearer secret in captured logs",
- !/Bearer\s+sk-|sk-[A-Za-z0-9._-]{12,}/.test(serialized),
- );
- for (const [i, entry] of proxyLog.slice(0, 8).entries()) {
- console.log(
- ` #${i + 1} ${entry.method} ${entry.url} status=${entry.status ?? "n/a"} model=${entry.summary?.model ?? "n/a"} tools=${(entry.summary?.toolNames ?? []).join(",") || "(none)"}`,
- );
- console.log(
- ` roles=${(entry.summary?.messageRoles ?? []).join(",")} prompt=${(entry.summary?.promptHead ?? "").replace(/\s+/g, " ").slice(0, 180)}`,
- );
- }
-}
-
-// ──────────────────────────────── main ─────────────────────────────────────
-
-async function main() {
- let proxy;
- let a;
- if (CAPTURE_PROXY) {
- proxy = await startCaptureProxy();
- }
- console.log(
- `MemFlywheel × Pi E2E — model=${MODEL} endpoint=${ACTIVE_ENDPOINT} key=${HAVE_KEY ? "yes" : "NO (only group C runs)"}`,
- );
- if (proxy) console.log(`Proxy capture enabled: ${proxy.url} -> ${ENDPOINT}`);
-
- try {
- await groupC(); // boundaries — always (deterministic, no key)
- a = await groupA(); // memory loop — A1 always, A2+ need key
- await groupB(); // skill loop — needs key
- if (process.env.PI_REAL === "1") await groupD();
- } catch (error) {
- record("fatal", "fail", error?.message ?? String(error));
- } finally {
- if (proxy) verifyProxyCapture();
- if (proxy) await proxy.close();
- }
-
- banner("SUMMARY");
- const by = (s) => results.filter((r) => r.status === s).length;
- for (const r of results) {
- const icon =
- r.status === "pass" ? "✅" : r.status === "warn" ? "⚠️ " : r.status === "skip" ? "⏭️ " : "❌";
- console.log(` ${icon} ${r.name}`);
- }
- console.log(
- `\n ${by("pass")} pass · ${by("warn")} warn · ${by("skip")} skip · ${by("fail")} fail`,
- );
- if (a) console.log(` memory root: ${a}`);
- process.exit(by("fail") > 0 ? 1 : 0);
-}
-
-main().catch((err) => {
- console.error("\nFATAL:", err);
- process.exit(1);
-});
diff --git a/examples/pi/e2e-recall-drill.mjs b/examples/pi/e2e-recall-drill.mjs
deleted file mode 100644
index d670e5b..0000000
--- a/examples/pi/e2e-recall-drill.mjs
+++ /dev/null
@@ -1,364 +0,0 @@
-/**
- * MemFlywheel × Pi — realistic recall E2E: hybrid-retrieval accuracy + latency +
- * 3rd-layer (raw-trace) drilling. Drives the REAL Pi lifecycle:
- * - recall via the Pi `context` event → onPromptBuild (hybrid: bge-m3+BM25+RRF)
- * - extract via scribe.onTurnEnd (the same method the Pi `agent_end` hook calls,
- * awaited here so the test is deterministic; the hook fires it detached)
- * LLM = DeepSeek, Embedding = Cloudflare bge-m3. Both behind a capture proxy.
- *
- * DEEPSEEK_KEY=sk-... CF_KEY=cfut-... CF_ACCT= node examples/pi/e2e-recall-drill.mjs
- */
-import http from "node:http";
-import fs from "node:fs";
-import path from "node:path";
-import { mkdtemp } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { createPiHarnessPort, createMemFlywheelHarnessRuntime } from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel, createOpenAIEmbeddingsModel } from "@memflywheel/model";
-import { createFileTools } from "@memflywheel/core";
-
-const DEEPSEEK_KEY = process.env.DEEPSEEK_KEY;
-const CF_KEY = process.env.CF_KEY;
-const CF_ACCT = process.env.CF_ACCT;
-const DS_MODEL = "deepseek-v4-flash";
-const EMB_MODEL = "@cf/baai/bge-m3";
-
-// ── capture proxy (one per upstream) ───────────────────────────────────────
-const capture = []; // { tag, url, ms, request, response }
-function startProxy(tag, upstream, auth) {
- return new Promise((resolve) => {
- const server = http.createServer(async (req, res) => {
- const chunks = [];
- for await (const c of req) chunks.push(c);
- const raw = Buffer.concat(chunks).toString("utf8");
- const t0 = Date.now();
- try {
- const up = await fetch(upstream + req.url, {
- method: req.method,
- headers: { "content-type": "application/json", authorization: `Bearer ${auth}` },
- body: req.method === "GET" ? undefined : raw,
- });
- const body = await up.text();
- capture.push({
- tag,
- url: req.url,
- ms: Date.now() - t0,
- status: up.status,
- request: safe(raw),
- response: safe(body),
- });
- res.writeHead(up.status, { "content-type": "application/json" });
- res.end(body);
- } catch (e) {
- capture.push({ tag, url: req.url, error: String(e) });
- res.writeHead(502);
- res.end(JSON.stringify({ error: String(e) }));
- }
- });
- server.listen(0, "127.0.0.1", () =>
- resolve({ port: server.address().port, close: () => server.close() }),
- );
- });
-}
-const safe = (s) => {
- try {
- return JSON.parse(s);
- } catch {
- return s;
- }
-};
-
-const llmProxy = await startProxy("LLM", "https://api.deepseek.com", DEEPSEEK_KEY);
-const embProxy = await startProxy("EMB", "https://api.cloudflare.com", CF_KEY);
-const LLM_BASE = `http://127.0.0.1:${llmProxy.port}/v1`;
-const EMB_BASE = `http://127.0.0.1:${embProxy.port}/client/v4/accounts/${CF_ACCT}/ai/v1`;
-
-// ── real Pi host (event emitter) ───────────────────────────────────────────
-function createMockPiHost() {
- const listeners = new Map();
- return {
- on(event, fn) {
- const s = listeners.get(event) ?? new Set();
- s.add(fn);
- listeners.set(event, s);
- return () => s.delete(fn);
- },
- async emit(event, payload, ctx) {
- const out = [];
- for (const fn of listeners.get(event) ?? []) out.push(await fn(payload, ctx));
- return out;
- },
- };
-}
-
-const root = await mkdtemp(path.join(tmpdir(), "memflywheel-pi-recall-"));
-const model = createOpenAIChatCompletionsModel({
- endpoint: LLM_BASE,
- apiKey: "proxy",
- model: DS_MODEL,
- maxTokens: 2048,
- temperature: 0,
-});
-const embedding = createOpenAIEmbeddingsModel({
- endpoint: EMB_BASE,
- apiKey: "proxy",
- model: EMB_MODEL,
-});
-
-const host = createMockPiHost();
-const port = createPiHarnessPort(host, { model });
-const { scribe, dispose } = createMemFlywheelHarnessRuntime({
- port,
- root,
- memoryIndexRetrieval: {
- mode: "auto",
- embeddingProvider: embedding,
- model: EMB_MODEL,
- limit: 3,
- minRecords: 1,
- },
-});
-
-const sep = (t) => console.log("\n" + "=".repeat(72) + "\n" + t + "\n" + "=".repeat(72));
-const piMsgs = (ms) => ms.map((m) => ({ role: m.role, content: [{ type: "text", text: m.text }] }));
-
-await host.emit("session_start", { sessionId: "pi" });
-
-// ── PHASE 1: extraction (awaited scribe.onTurnEnd = the agent_end path) ──────
-const turns = [
- [
- {
- role: "user",
- text: "Hi, I'm Lin Wei — backend engineer at iFlytek, lead on the MemFlywheel project.",
- timestamp: "2026-06-23T09:00:00Z",
- },
- { role: "assistant", text: "Hi Lin Wei." },
- ],
- [
- {
- role: "user",
- text: "Working preferences: always reply to me in Chinese, and keep answers short and to the point.",
- timestamp: "2026-06-23T09:02:00Z",
- },
- { role: "assistant", text: "好的。" },
- ],
- [
- {
- role: "user",
- text: "Team repo rule: every change goes through a pull request reviewed by a committer; never push directly to main.",
- timestamp: "2026-06-23T09:04:00Z",
- },
- { role: "assistant", text: "Understood." },
- ],
- [
- {
- role: "user",
- text: "My editor is Neovim, and I always use 2-space indentation.",
- timestamp: "2026-06-23T09:06:00Z",
- },
- { role: "assistant", text: "Got it." },
- ],
- [
- {
- role: "user",
- text: "Our backend is written in Go and we deploy on Kubernetes.",
- timestamp: "2026-06-23T09:08:00Z",
- },
- { role: "assistant", text: "Noted." },
- ],
- [
- {
- role: "user",
- text: "I'm based in Hefei, timezone UTC+8. And I'm vegetarian, so keep that in mind for any food suggestions.",
- timestamp: "2026-06-23T09:12:00Z",
- },
- { role: "assistant", text: "Noted." },
- ],
- [
- {
- role: "user",
- text: "Lock down our production Postgres config on record: host db-prod-3.internal, port 5432, max connection pool 40, statement_timeout 30s, pinned to PostgreSQL 15.4.",
- timestamp: "2026-06-23T09:14:00Z",
- },
- { role: "assistant", text: "Recorded." },
- ],
-];
-sep("PHASE 1 — EXTRACTION (real Pi onTurnEnd path)");
-const tEx = Date.now();
-for (let i = 0; i < turns.length; i++) {
- const r = await scribe.onTurnEnd({ sessionId: "pi", messages: turns[i] });
- console.log(`turn ${i + 1}: ${JSON.stringify(r?.result ?? r)}`);
-}
-console.log(`extraction wall: ${((Date.now() - tEx) / 1000).toFixed(1)}s`);
-
-const memFiles = [];
-for (const t of ["identity", "preference", "style", "workflow", "context", "ambient"]) {
- const dir = path.join(root, t);
- if (!fs.existsSync(dir)) continue;
- for (const f of fs.readdirSync(dir)) memFiles.push(`${t}/${f}`);
-}
-sep(`CORPUS (${memFiles.length} memories)`);
-console.log(fs.readFileSync(path.join(root, "MEMORY.md"), "utf8"));
-
-// ── recall through the REAL Pi `context` event ──────────────────────────────
-async function piRecall(query) {
- const before = capture.filter((c) => c.tag === "EMB").length;
- const t0 = Date.now();
- const results = await host.emit("context", { sessionId: "pi", query, messages: [] });
- const wall = Date.now() - t0;
- const injected = results.at(-1); // { messages: [...] } from piContextResultFromPromptBuild
- const msgs = injected?.messages ?? (Array.isArray(injected) ? injected : []);
- const plain = msgs
- .map((m) => {
- const c = m?.content;
- if (typeof c === "string") return c;
- if (Array.isArray(c)) return c.map((p) => p?.text ?? "").join("");
- return "";
- })
- .join("\n\n");
- const embCalls = capture.filter((c) => c.tag === "EMB").slice(before);
- const embMs = embCalls.reduce((a, c) => a + (c.ms || 0), 0);
- const paths = [...plain.matchAll(/\]\(([^)]+\.md)\)/g)].map((m) => m[1]);
- const hybrid = /混合检索/.test(plain);
- return { paths, plain, wall, embMs, local: wall - embMs, hybrid };
-}
-
-// ── PHASE 2: accuracy + latency ─────────────────────────────────────────────
-const labeled = [
- { q: "用户平时用什么代码编辑器?", expect: "neovim|editor|indent" },
- { q: "生产环境的数据库用的是什么?", expect: "postgres|database|db" },
- { q: "代码提交合并要遵守什么流程?", expect: "pull|pr|workflow|merge|review|repo" },
- { q: "后端是用什么编程语言写的?", expect: "go|backend|stack|kubernetes" },
- { q: "用户在哪个城市、什么时区?", expect: "hefei|timezone|utc|location" },
- { q: "给用户推荐餐厅要注意什么饮食?", expect: "vegetarian|diet|food" },
- { q: "用户叫什么名字、在哪家公司?", expect: "lin-wei|identity" },
- { q: "回复用户应该用哪种语言?", expect: "language|chinese|reply" },
- { q: "db-prod-3.internal 这台机器是做什么用的?", expect: "postgres|database|db" }, // BM25 stress
-];
-sep("PHASE 2 — RETRIEVAL ACCURACY + LATENCY (via Pi context event)");
-let h1 = 0,
- h3 = 0,
- mrr = 0;
-const lat = [];
-for (const { q, expect } of labeled) {
- const re = new RegExp(expect, "i");
- const r = await piRecall(q);
- lat.push(r);
- const rank = r.paths.findIndex((p) => re.test(p)) + 1;
- if (rank === 1) h1++;
- if (rank >= 1 && rank <= 3) h3++;
- if (rank >= 1) mrr += 1 / rank;
- console.log(
- `\nQ: ${q}\n top${r.paths.length}: ${r.paths.join(" | ")}\n /${expect}/ -> rank ${rank || "MISS"} [hybrid=${r.hybrid} embed ${r.embMs}ms local ${r.local}ms total ${r.wall}ms]`,
- );
-}
-const n = labeled.length,
- med = (a) => a.slice().sort((x, y) => x - y)[Math.floor(a.length / 2)];
-sep("PHASE 2 SCORE");
-console.log(
- `hit@1=${h1}/${n} (${((100 * h1) / n).toFixed(0)}%) hit@3=${h3}/${n} (${((100 * h3) / n).toFixed(0)}%) MRR=${(mrr / n).toFixed(3)}`,
-);
-console.log(
- `latency median: embed ${med(lat.map((x) => x.embMs))}ms local(BM25+RRF+cosine) ${med(lat.map((x) => x.local))}ms total ${med(lat.map((x) => x.wall))}ms`,
-);
-
-// ── PHASE 3: 3rd-layer drill via a tool-calling agent fed the REAL Pi prelude ─
-// (Pi's own agent runtime is external/not in this repo, so the loop is driven
-// here, but the system prompt is exactly what the Pi context event injected.)
-const tools = createFileTools();
-const toolCtx = { root, mode: "files" };
-const oaiTools = tools.map((t) => ({
- type: "function",
- function: { name: t.name, description: t.description, parameters: t.inputSchema },
-}));
-async function runAgent(systemPrompt, question, maxSteps = 8) {
- const messages = [
- { role: "system", content: systemPrompt },
- { role: "user", content: question },
- ];
- const trace = [];
- for (let s = 0; s < maxSteps; s++) {
- const resp = await fetch(`${LLM_BASE}/chat/completions`, {
- method: "POST",
- headers: { "content-type": "application/json", authorization: "Bearer proxy" },
- body: JSON.stringify({
- model: DS_MODEL,
- messages,
- tools: oaiTools,
- tool_choice: "auto",
- temperature: 0,
- }),
- }).then((r) => r.json());
- const msg = resp.choices[0].message;
- messages.push(msg);
- const tcs = msg.tool_calls || [];
- if (!tcs.length) return { answer: msg.content, trace };
- for (const tc of tcs) {
- let args = {};
- try {
- args = JSON.parse(tc.function.arguments || "{}");
- } catch {}
- const tool = tools.find((t) => t.name === tc.function.name);
- let out;
- try {
- out = await tool.handler(args, toolCtx);
- } catch (e) {
- out = { text: String(e) };
- }
- trace.push({
- tool: tc.function.name,
- path: args.filePath || args.pattern || args.path,
- offset: args.offset,
- limit: args.limit,
- });
- messages.push({
- role: "tool",
- tool_call_id: tc.id,
- content: (out.text || "").slice(0, 4000),
- });
- }
- }
- return { answer: "(max steps)", trace };
-}
-
-const detailQ = "我们生产 Postgres 的连接池最大是多少个连接?statement timeout 设的是多少?";
-const rc = await piRecall(detailQ);
-// the drill agent's system prompt = exactly what the Pi context event injected
-const injectedText = rc.plain;
-const hintSuffix =
- "\n\n如果某条记忆正文不足以回答,正文末尾的 ## Sources 指向原始对话轨迹文件(形如 .memflywheel/sources/xxx.jsonl#L13-L20)。可用 read 工具按该文件与行号区间读取原始细节。";
-
-sep("PHASE 3A — DRILL (product as-is: only what Pi injected)");
-console.log("Q:", detailQ);
-const a = await runAgent(injectedText, detailQ);
-console.log("tools:", JSON.stringify(a.trace));
-console.log(
- "drilled into .memflywheel/sources?",
- a.trace.some((t) => String(t.path || "").includes(".memflywheel/sources")),
-);
-console.log("ANSWER:", a.answer);
-
-sep("PHASE 3B — DRILL (with explicit sources hint)");
-console.log("Q:", detailQ);
-const b = await runAgent(injectedText + hintSuffix, detailQ);
-console.log("tools:", JSON.stringify(b.trace));
-console.log(
- "drilled into .memflywheel/sources?",
- b.trace.some((t) => String(t.path || "").includes(".memflywheel/sources")),
-);
-console.log("ANSWER:", b.answer);
-
-sep("GROUND TRUTH — postgres memory body");
-const pg = memFiles.find((f) => /postgres|database|db|prod/i.test(f));
-if (pg) console.log(`--- ${pg} ---\n` + fs.readFileSync(path.join(root, pg), "utf8"));
-
-sep("CAPTURE DIGEST");
-console.log(
- `LLM calls: ${capture.filter((c) => c.tag === "LLM").length} EMB calls: ${capture.filter((c) => c.tag === "EMB").length}`,
-);
-console.log("memory root:", root);
-
-dispose();
-llmProxy.close();
-embProxy.close();
-sep("DONE");
diff --git a/examples/pi/e2e-strict.mjs b/examples/pi/e2e-strict.mjs
deleted file mode 100644
index 6b29751..0000000
--- a/examples/pi/e2e-strict.mjs
+++ /dev/null
@@ -1,634 +0,0 @@
-/**
- * MemFlywheel × Pi — STRICT end-to-end test (real model: DeepSeek).
- *
- * Difference from e2e-deepseek.mjs: there is NO "warn" escape hatch. Every
- * behavioral assertion is pass/fail, and the process exits non-zero on ANY fail.
- * The point is to discover whether the memory + skill closed loop ACTUALLY works
- * end-to-end against a real model — not to look green.
- *
- * Honesty rules baked in:
- * - Skill-loop signals are NOT faked. We do NOT inject toolCalls:99. We lower only
- * minDoneTurns to 1 (legitimate host config) and feed a trajectory with >=6 real
- * tool calls so the gate's minToolCalls:6 is satisfied by the DERIVED count.
- * - A created skill is validated as a real package (validateLearnedSkillPackage),
- * not merely "a file exists".
- * - The reverse proxy ASSERTS the captured request (system prompt + 6 tool schemas),
- * it does not just log.
- * - Published learned-skill packages must not contain root hidden metadata files.
- *
- * SECURITY: the key is read ONLY from the env; never hardcoded, never written to a
- * file, never stored in the capture log (only forwarded upstream in the header).
- *
- * export MEMFLYWHEEL_LLM_API_KEY=sk-...
- * export MEMFLYWHEEL_LLM_ENDPOINT=https://api.deepseek.com/v1
- * export MEMFLYWHEEL_LLM_MODEL=deepseek-v4-flash
- * node examples/pi/e2e-strict.mjs
- */
-
-import { mkdtemp, mkdir, readFile, readdir } from "node:fs/promises";
-import { createServer } from "node:http";
-import https from "node:https";
-import { tmpdir } from "node:os";
-import path from "node:path";
-
-import { createMemFlywheelHarnessRuntime } from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import {
- validateLearnedSkillPackage,
- LearnedSkillValidationError,
- createLearnedSkillStore,
-} from "@memflywheel/skills";
-
-// ───────────────────────────── config ──────────────────────────────────────
-
-const ENDPOINT =
- process.env.MEMFLYWHEEL_LLM_ENDPOINT ??
- process.env.DEEPSEEK_BASE_URL ??
- "https://api.deepseek.com/v1";
-const MODEL =
- process.env.MEMFLYWHEEL_LLM_MODEL ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash";
-const API_KEY =
- process.env.MEMFLYWHEEL_LLM_API_KEY ?? process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY;
-const HAVE_KEY = Boolean(API_KEY);
-
-let ACTIVE_ENDPOINT = ENDPOINT;
-let ACTIVE_API_KEY = API_KEY;
-const proxyLog = [];
-
-function buildModel() {
- return createOpenAIChatCompletionsModel({
- endpoint: ACTIVE_ENDPOINT,
- apiKey: ACTIVE_API_KEY,
- model: MODEL,
- maxTokens: 4096,
- temperature: 0,
- });
-}
-
-// ─────────────────────────── result bookkeeping ────────────────────────────
-
-const results = [];
-function record(name, status, detail = "") {
- results.push({ name, status, detail });
- const mark = status === "pass" ? "✅" : status === "skip" ? "·" : "❌";
- console.log(` ${mark} ${name}${detail ? ` — ${detail}` : ""}`);
-}
-function check(name, cond, detail = "") {
- record(name, cond ? "pass" : "fail", cond ? "" : detail || "assertion failed");
-}
-function banner(t) {
- console.log(`\n── ${t}`);
-}
-const by = (s) => results.filter((r) => r.status === s).length;
-
-// ─────────────────────────── capture proxy ─────────────────────────────────
-
-function summarize(body) {
- const messages = Array.isArray(body?.messages) ? body.messages : [];
- const tools = Array.isArray(body?.tools) ? body.tools : [];
- return {
- model: body?.model,
- roles: messages.map((m) => m?.role),
- systemHead: (messages.find((m) => m?.role === "system")?.content ?? "").slice(0, 400),
- toolNames: tools.map((t) => t?.function?.name ?? t?.name).filter(Boolean),
- toolSchemas: tools.map((t) => ({
- name: t?.function?.name ?? t?.name,
- hasParams: Boolean(t?.function?.parameters ?? t?.parameters),
- props: Object.keys((t?.function?.parameters ?? t?.parameters ?? {}).properties ?? {}),
- })),
- hasToolResult: messages.some((m) => m?.role === "tool"),
- hasAssistantToolCall: messages.some(
- (m) => m?.role === "assistant" && Array.isArray(m?.tool_calls) && m.tool_calls.length > 0,
- ),
- };
-}
-
-async function startCaptureProxy() {
- const upstreamUrl = new URL(ENDPOINT.replace(/\/+$/, ""));
- const server = createServer((req, res) => {
- const chunks = [];
- req.on("data", (c) => chunks.push(c));
- req.on("end", () => {
- const raw = Buffer.concat(chunks).toString("utf8");
- let body;
- try {
- body = raw ? JSON.parse(raw) : undefined;
- } catch {
- body = undefined;
- }
- // Record only a structured, key-free summary. Never store headers/raw key.
- proxyLog.push({ url: req.url, summary: summarize(body) });
-
- const suffix = (req.url ?? "").replace(/^\/v1(?=\/|$)/, "");
- const upstreamPath = `${upstreamUrl.pathname.replace(/\/+$/, "")}${suffix}`;
- const proxied = https.request(
- {
- hostname: upstreamUrl.hostname,
- port: 443,
- path: upstreamPath,
- method: req.method,
- headers: {
- ...req.headers,
- host: upstreamUrl.hostname,
- authorization: `Bearer ${API_KEY}`,
- },
- },
- (up) => {
- res.writeHead(up.statusCode ?? 502, up.headers);
- up.pipe(res);
- },
- );
- proxied.on("error", (e) => {
- res.writeHead(502, { "content-type": "application/json" });
- res.end(JSON.stringify({ error: `proxy upstream error: ${e.message}` }));
- });
- proxied.end(raw);
- });
- });
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
- const { port } = server.address();
- ACTIVE_ENDPOINT = `http://127.0.0.1:${port}/v1`;
- ACTIVE_API_KEY = "memflywheel-local-proxy-token"; // the SDK never sees the real key
- return server;
-}
-
-// ──────────────────────────── fs helpers ───────────────────────────────────
-
-async function walkFiles(dir, rel = "") {
- const out = [];
- let ents;
- try {
- ents = await readdir(dir, { withFileTypes: true });
- } catch {
- return out;
- }
- for (const ent of ents) {
- const abs = path.join(dir, ent.name);
- const r = rel ? `${rel}/${ent.name}` : ent.name;
- if (ent.isDirectory()) out.push(...(await walkFiles(abs, r)));
- else out.push({ rel: r, abs });
- }
- return out;
-}
-async function readSafe(p) {
- try {
- return await readFile(p, "utf8");
- } catch {
- return "";
- }
-}
-async function dumpRoot(root) {
- const files = await walkFiles(root);
- const index = await readSafe(path.join(root, "MEMORY.md"));
- return { files, index };
-}
-async function readSkillPackage(skillsRoot, slug) {
- const files = {};
- for (const f of await walkFiles(path.join(skillsRoot, slug))) {
- files[f.rel] = await readSafe(f.abs);
- }
- return files;
-}
-
-// ──────────────────────────── transcripts ──────────────────────────────────
-
-const factsTranscript = [
- {
- role: "user",
- text: "我叫 Kai,是后端工程师。咖啡只喝美式。回复请尽量简洁。我们的项目部署在 ap-singapore 区域。",
- },
- {
- role: "assistant",
- text: "记下了,Kai:后端工程师、只喝美式、偏好简洁回复、部署在 ap-singapore。",
- },
-];
-
-const secretTranscript = [
- {
- role: "user",
- text: "顺便存一下:我的发布 token 是 " + "sk-" + "LEAKTEST0123456789ABCD(这个别存)。",
- },
- { role: "assistant", text: "好的,发布习惯已记,敏感凭据不会保存。" },
-];
-
-// A clearly reusable RELEASE procedure with >=6 real tool calls in the trajectory,
-// so the gate's minToolCalls:6 is met by the DERIVED count (no faking).
-const procedureTranscript = [
- { role: "user", text: "帮我把这个 monorepo 发个版,记一下标准流程,以后照着做。" },
- {
- role: "assistant",
- text: "开始执行标准发布流程。先构建。",
- toolCalls: [
- {
- name: "bash",
- input: { command: "pnpm -r build" },
- output: "all 8 packages built, 0 errors",
- },
- ],
- },
- {
- role: "assistant",
- text: "构建通过,跑全量测试。",
- toolCalls: [
- { name: "bash", input: { command: "pnpm -r test" }, output: "240 passed, 0 failed" },
- ],
- },
- {
- role: "assistant",
- text: "测试通过,更新 changelog。",
- toolCalls: [
- { name: "edit", input: { filePath: "CHANGELOG.md", text: "## 0.2.0" }, output: "edited" },
- { name: "bash", input: { command: "git add CHANGELOG.md" }, output: "staged" },
- ],
- },
- {
- role: "assistant",
- text: "发布到 registry 并打 tag。",
- toolCalls: [
- {
- name: "bash",
- input: { command: "pnpm -r publish --access public" },
- output: "published 8 packages",
- },
- {
- name: "bash",
- input: { command: "git tag v0.2.0 && git push --tags" },
- output: "tag pushed",
- },
- ],
- },
- { role: "user", text: "完美,这个流程以后每次发版都这样走。" },
- { role: "assistant", text: "已确认:构建→测试→改 changelog→发布→打 tag,这是固定发布流程。" },
-];
-
-// ════════════════ GROUP S0 · deterministic mechanics (no model) ═════════════
-
-async function groupDeterministic() {
- banner("S0 · 确定性机制(不需要模型)");
-
- // recall-only works without a model
- {
- const root = await mkdtemp(path.join(tmpdir(), "ms-strict-rc-"));
- const { scribe, mode } = createMemFlywheelHarnessRuntime({ root, mode: "recall-only" });
- check("S0.1 recall-only mode classified", mode === "recall-only", `mode=${mode}`);
- await scribe.onSessionStart({ sessionId: "s0" });
- const ctx = await scribe.onPromptBuild({ sessionId: "s0" });
- check("S0.1 prompt-build returns a context", typeof ctx?.enabled === "boolean");
- }
-
- // fail-fast: no model + not recall-only must THROW (no silent downgrade)
- {
- const root = await mkdtemp(path.join(tmpdir(), "ms-strict-ff-"));
- let threw = false;
- try {
- createMemFlywheelHarnessRuntime({ root });
- } catch (e) {
- threw = /canonical model|extraction agent/i.test(e?.message ?? "");
- }
- check("S0.2 fail-fast: no model & not recall-only → throws", threw);
- }
-
- // store exposes NO execution method (MemFlywheel never runs skills)
- {
- const root = await mkdtemp(path.join(tmpdir(), "ms-strict-store-"));
- const store = createLearnedSkillStore({
- skillsRoot: path.join(root, "skills"),
- checkpointRoot: path.join(root, "checkpoints"),
- });
- const forbidden = ["execute", "run", "spawn", "invoke", "exec"];
- check(
- "S0.3 store exposes no execution method",
- forbidden.every((m) => typeof store[m] !== "function"),
- `keys: ${Object.keys(store).join(",")}`,
- );
- }
-
- // validateLearnedSkillPackage rejects a malformed package
- {
- let rejected = false;
- try {
- validateLearnedSkillPackage({
- slug: "memflywheel-learned-bad",
- files: { "SKILL.md": "no frontmatter, no sections" },
- });
- } catch (e) {
- rejected = e instanceof LearnedSkillValidationError;
- }
- check("S0.4 validate rejects a malformed skill package", rejected);
- }
-}
-
-// ════════════════ GROUP M · 记忆闭环 (real model) ═══════════════════════════
-
-const VALID_TYPES = ["identity", "preference", "style", "workflow", "context", "ambient"];
-
-async function groupMemory() {
- if (!HAVE_KEY) {
- for (const n of ["M1 extraction", "M2 recall", "M3 privacy", "M4 dream"])
- record(n, "skip", "no key");
- return;
- }
- const root = await mkdtemp(path.join(tmpdir(), "ms-strict-M-"));
- const sessionId = "M";
- const { scribe } = createMemFlywheelHarnessRuntime({ model: buildModel(), root });
- await scribe.onSessionStart({ sessionId });
-
- banner("M1 · turn-end 抽取真的写出 typed memory 文件 + 合法 frontmatter + MEMORY.md 同步");
- const turn = await scribe.onTurnEnd({ sessionId, messages: factsTranscript });
- console.log(" onTurnEnd:", JSON.stringify(turn).slice(0, 160));
- const dump = await dumpRoot(root);
- const memFiles = dump.files.filter((f) => f.rel.endsWith(".md") && f.rel !== "MEMORY.md");
- check(
- "M1 at least one memory file written",
- memFiles.length > 0,
- `files=${dump.files.map((f) => f.rel).join(",")}`,
- );
- // each written memory file must sit in a valid typed dir AND declare a valid frontmatter type
- let typedOk = memFiles.length > 0;
- for (const f of memFiles) {
- const topDir = f.rel.split("/")[0];
- const content = await readSafe(f.abs);
- const typeMatch = content.match(/^type:\s*(\S+)/m);
- const ft = typeMatch?.[1];
- if (!VALID_TYPES.includes(topDir) || !VALID_TYPES.includes(ft)) {
- typedOk = false;
- console.log(` ✗ ${f.rel}: dir=${topDir} frontmatter.type=${ft}`);
- }
- }
- check("M1 every memory file is in a valid typed dir with valid frontmatter.type", typedOk);
- check(
- "M1 MEMORY.md non-empty and indexes the file",
- dump.index.trim().length > 0 &&
- memFiles.some(
- (f) =>
- dump.index.includes(f.rel.split("/").pop().replace(/\.md$/, "")) ||
- dump.index.includes(f.rel),
- ),
- );
-
- banner("M2 · 跨轮召回:上轮事实出现在下轮 prompt-build");
- const ctx = await scribe.onPromptBuild({ sessionId });
- const recall = `${ctx.systemPrompt ?? ""}\n${ctx.preludePrompt ?? ""}`;
- const signals = [
- /美式|coffee|preference|偏好/i,
- /简洁|style|风格|tone/i,
- /Kai|identity|身份/i,
- /singapore|ap-singapore|部署|context/i,
- ].filter((re) => re.test(recall)).length;
- console.log(" prelude(head):", (ctx.preludePrompt ?? "").slice(0, 220).replace(/\n/g, " "));
- check(
- "M2 prior memory recalled into next prompt (>=2 signals)",
- signals >= 2,
- `signals=${signals}`,
- );
- // recall injects INDEX, not full bodies
- check(
- "M2 prelude looks like an index (not raw bodies dump)",
- /可用记忆|Available memories|MEMORY\.md|- \[/i.test(recall),
- );
-
- banner("M3 · 隐私:transcript 里的 secret 永不落盘");
- await scribe.onTurnEnd({ sessionId, messages: secretTranscript });
- const dump3 = await dumpRoot(root);
- const allText =
- (await Promise.all(dump3.files.map((f) => readSafe(f.abs)))).join("\n") + dump3.index;
- check("M3 secret NOT persisted to disk", !new RegExp("sk-" + "LEAKTEST0123456789").test(allText));
-
- banner("M4 · idle → dream 整理后索引仍可用,且记忆未丢");
- const before = (await dumpRoot(root)).files.filter(
- (f) => f.rel.endsWith(".md") && f.rel !== "MEMORY.md",
- ).length;
- let dreamErr = null;
- try {
- await scribe.onIdle({ force: true });
- } catch (e) {
- dreamErr = e?.message ?? String(e);
- }
- const after = await dumpRoot(root);
- const afterCount = after.files.filter(
- (f) => f.rel.endsWith(".md") && f.rel !== "MEMORY.md",
- ).length;
- check("M4 dream ran without throwing", dreamErr === null, dreamErr ?? "");
- check(
- "M4 index still parseable after dream",
- typeof after.index === "string" && after.index.length >= 0,
- );
- check(
- "M4 dream did not destroy memories",
- afterCount >= 1 && afterCount <= before,
- `before=${before} after=${afterCount}`,
- );
-
- await scribe.onSessionEnd({ sessionId });
-}
-
-// ════════════════ GROUP K · 技能闭环 (real model, honest gate) ══════════════
-
-async function attemptEvolution(i) {
- const root = await mkdtemp(path.join(tmpdir(), `ms-strict-K${i}-`));
- const skillsRoot = await mkdtemp(path.join(tmpdir(), `ms-strict-K${i}-skills-`));
- const checkpointRoot = await mkdtemp(path.join(tmpdir(), `ms-strict-K${i}-ckpt-`));
- await mkdir(skillsRoot, { recursive: true });
- const { scribe } = createMemFlywheelHarnessRuntime({
- model: buildModel(),
- root,
- learnedSkills: { skillsRoot, checkpointRoot }, // DEFAULT review packet — test the SHIPPED path
- // Honest gate: only lower minDoneTurns to 1 (one turn). minToolCalls:6 stays intact and
- // is satisfied by the 6 REAL tool calls in procedureTranscript (DERIVED, not faked).
- learningLoop: { gate: { minDoneTurns: 1, cooldownTurns: 0, minToolCalls: 6 } },
- });
- const sessionId = "K";
- await scribe.onSessionStart({ sessionId });
- try {
- const t1 = await scribe.onTurnEnd({ sessionId, messages: procedureTranscript });
- const slugs = (await readdir(skillsRoot, { withFileTypes: true }))
- .filter((d) => d.isDirectory())
- .map((d) => d.name);
- if (slugs.length === 1) {
- return { ok: true, slug: slugs[0], skillsRoot, loop: t1?.learningLoop, scribe, sessionId };
- }
- return { ok: false, error: `no skill dir produced (slugs=[${slugs.join(",")}])` };
- } catch (e) {
- return { ok: false, error: e?.message ?? String(e) };
- }
-}
-
-async function groupSkill() {
- if (!HAVE_KEY) {
- for (const n of ["K1 evolve", "K2 valid package", "K3 recall", "K4 decision", "K5 leak"])
- record(n, "skip", "no key");
- return;
- }
- const MAX = Number(process.env.K_ATTEMPTS ?? 4); // K_ATTEMPTS=1 => single-shot, production-like (no retry softening)
- banner(`K1 · 富工具轨迹 → 技能演化真的产出 learned skill 包(最多 ${MAX} 次尝试,暴露不稳定性)`);
- let success = null;
- const failures = [];
- for (let i = 1; i <= MAX && !success; i += 1) {
- const r = await attemptEvolution(i);
- if (r.ok) {
- success = r;
- console.log(` attempt ${i}: ✅ produced ${r.slug}`);
- } else {
- failures.push(r.error);
- console.log(` attempt ${i}: ❌ ${r.error}`);
- }
- }
- check(
- `K1 skill loop produced a learned skill within ${MAX} attempts`,
- Boolean(success),
- success ? "" : `all ${MAX} attempts failed: ${failures.join(" | ")}`,
- );
- const attemptsMade = failures.length + (success ? 1 : 0);
- console.log(
- ` ◇ reliability: succeeded=${success ? "yes" : "no"} after ${attemptsMade} attempt(s); ${failures.length} threw before success`,
- );
- if (!success) {
- for (const n of ["K2 valid package", "K3 recall", "K4 decision", "K5 leak"])
- record(n, "fail", "no successful evolution to inspect");
- return;
- }
- const { slug, skillsRoot, loop, scribe, sessionId } = success;
-
- banner("K2 · 产出物是合法技能包(validateLearnedSkillPackage 通过,传裸 slug)");
- const files = await readSkillPackage(skillsRoot, slug);
- console.log(" package files:", Object.keys(files).join(", "));
- console.log("\n ── SKILL.md\n" + (files["SKILL.md"] ?? "").replace(/^/gm, " ").slice(0, 900));
- const bareSlug = slug.replace(/^memflywheel-learned-/, "");
- let valid = false;
- let validErr = "";
- try {
- validateLearnedSkillPackage({ slug: bareSlug, files });
- valid = true;
- } catch (e) {
- validErr = e?.message ?? String(e);
- }
- check("K2 created package passes validateLearnedSkillPackage", valid, validErr);
-
- banner("K3 · 技能路由召回:新技能以 name + path 出现在 skillPrelude");
- const ctx = await scribe.onPromptBuild({ sessionId });
- const sp = ctx.skillPreludePrompt ?? "";
- console.log(" skillPrelude:\n" + (sp ? sp.replace(/^/gm, " ") : " (empty)"));
- check(
- "K3 skill recalled with name + path in skillPrelude",
- sp.includes(slug) && /path:/i.test(sp),
- "skillPrelude missing slug or path:",
- );
-
- banner("K4 · 协调决策是 create(真的演化,不是 noop)");
- const decision = loop?.skillEvolution?.value?.coordination?.decision;
- console.log(" coordination decision:", decision);
- check(
- "K4 coordination decision is create (not noop)",
- decision === "create",
- `decision=${decision}`,
- );
-
- banner("K5 · 卫生:发布技能包不含根级隐藏元数据文件");
- const leaked = Object.keys(files).some((f) => f.startsWith("."));
- check(
- "K5 no root hidden metadata files in published package",
- !leaked,
- "root hidden metadata file shipped inside the published skill",
- );
-
- banner("K6 · 记忆↔技能联动:技能创建后自动触发 dream 压缩记忆");
- console.log(" dream:", JSON.stringify(loop?.dream ?? null).slice(0, 200));
- check(
- "K6 skill creation auto-triggered memory dream (联动)",
- loop?.dream?.ran === true,
- `dream=${JSON.stringify(loop?.dream)}`,
- );
-
- await scribe.onSessionEnd({ sessionId });
-}
-
-// ════════════════ GROUP P · 反向代理:断言原始请求(非仅打印)═════════════════
-
-function groupProxyAssertions() {
- if (!HAVE_KEY) {
- record("P proxy capture", "skip", "no key");
- return;
- }
- banner("P · 代理抓到的 DeepSeek 原始请求符合预期(断言,非打印)");
- check(
- "P captured at least one upstream request",
- proxyLog.length > 0,
- `captured=${proxyLog.length}`,
- );
-
- // an extraction/skill request must carry a MemFlywheel system prompt
- const sawMemSystem = proxyLog.some((e) =>
- /memory extraction|learned-skill|file tools|MEMORY\.md|记忆|技能/i.test(
- e.summary?.systemHead ?? "",
- ),
- );
- check("P a MemFlywheel system prompt reached the model", sawMemSystem);
-
- // some request must expose the six file tools WITH schemas (not just names)
- const SIX = ["read", "write", "edit", "bash", "glob", "grep"];
- const withSix = proxyLog.find((e) => SIX.every((n) => (e.summary?.toolNames ?? []).includes(n)));
- check(
- "P the six file tools were advertised to the model",
- Boolean(withSix),
- `toolSets=${proxyLog.map((e) => (e.summary?.toolNames ?? []).length).join(",")}`,
- );
- if (withSix) {
- const schemasOk = SIX.every((n) => {
- const s = withSix.summary.toolSchemas.find((x) => x.name === n);
- return s && s.hasParams;
- });
- check("P each of the six tools carries a parameters schema", schemasOk);
- } else {
- check(
- "P each of the six tools carries a parameters schema",
- false,
- "no request had all six tools",
- );
- }
-
- // a real tool-call round-trip must have happened in at least one captured request
- const sawRoundTrip =
- proxyLog.some((e) => e.summary?.hasAssistantToolCall) &&
- proxyLog.some((e) => e.summary?.hasToolResult);
- check("P a real tool_call/tool_result round-trip was observed", sawRoundTrip);
-
- // secret hygiene: the real key must never appear in the capture log
- const serialized = JSON.stringify(proxyLog);
- check("P no API key in capture log", !API_KEY || !serialized.includes(API_KEY));
- check(
- "P no bearer/sk- secret in capture log",
- !/Bearer\s+sk-|sk-[A-Za-z0-9._-]{12,}/.test(serialized),
- );
-}
-
-// ──────────────────────────────── main ─────────────────────────────────────
-
-async function main() {
- console.log(
- `MemFlywheel × Pi — STRICT E2E — model=${MODEL} endpoint=${ENDPOINT} key=${HAVE_KEY ? "YES" : "NO"}`,
- );
- let proxy = null;
- if (HAVE_KEY) proxy = await startCaptureProxy();
-
- try {
- await groupDeterministic();
- await groupMemory();
- await groupSkill();
- groupProxyAssertions();
- } finally {
- if (proxy) await new Promise((r) => proxy.close(r));
- }
-
- console.log(`\n ${by("pass")} pass · ${by("skip")} skip · ${by("fail")} fail`);
- if (by("fail") > 0) {
- console.log("\n FAILURES:");
- for (const r of results.filter((r) => r.status === "fail"))
- console.log(` ❌ ${r.name} — ${r.detail}`);
- }
- process.exit(by("fail") > 0 ? 1 : 0);
-}
-
-main().catch((e) => {
- console.error("FATAL:", e?.stack ?? e);
- process.exit(2);
-});
diff --git a/examples/pi/run.mjs b/examples/pi/run.mjs
deleted file mode 100644
index f74e836..0000000
--- a/examples/pi/run.mjs
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Runnable Pi example / smoke test.
- *
- * Drives a Pi-shaped host through the real Pi lifecycle events
- * (session_start → context → agent_end → session_shutdown) and prints the
- * resulting memory. Under USE_FAKE the extraction subagent is a scripted
- * canonical model (list → save two memories → decline a high-risk secret); the
- * script exits non-zero if the expected memories are missing or the secret leaked.
- *
- * USE_FAKE=1 node examples/pi/run.mjs # offline, deterministic
- * MEMFLYWHEEL_LLM_API_KEY=... node examples/pi/run.mjs # real model (tools endpoint)
- */
-
-import { mkdtemp, readFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import {
- createMemFlywheelHarnessRuntime,
- createPiHarnessPort,
- piAdapter,
- connect,
-} from "@iflytekopensource/adapters";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-import { createFakeModel } from "../shared/fake-model.mjs";
-import { transcript } from "../shared/transcript.mjs";
-
-/** A minimal EventEmitter-ish Pi host. */
-function createMockPiHost() {
- const listeners = new Map();
- return {
- on(event, fn) {
- const set = listeners.get(event) ?? new Set();
- set.add(fn);
- listeners.set(event, set);
- return () => set.delete(fn);
- },
- async emit(event, payload, ctx) {
- const results = [];
- for (const fn of listeners.get(event) ?? []) {
- results.push(await fn(payload, ctx));
- }
- return results;
- },
- };
-}
-
-function piMessagesFromTranscript(messages) {
- return messages.map((message) => ({
- role: message.role,
- content: [{ type: "text", text: message.text }],
- }));
-}
-
-const useFake = process.env.USE_FAKE === "1";
-const root = await mkdtemp(path.join(tmpdir(), "memflywheel-pi-"));
-
-const model = useFake ? createFakeModel() : createOpenAIChatCompletionsModel();
-const host = createMockPiHost();
-const port = createPiHarnessPort(host, { model });
-const { scribe, dispose } = createMemFlywheelHarnessRuntime({ port, root });
-
-await host.emit("session_start", { sessionId: "demo" });
-
-// Prompt build: Pi's context hook returns the transformed message list.
-const contextResults = await host.emit("context", { sessionId: "demo", messages: [] });
-console.log("[context] injected messages:", contextResults.at(-1)?.messages?.length ?? 0);
-
-await host.emit("agent_end", { sessionId: "demo", messages: piMessagesFromTranscript(transcript) });
-await scribe.onIdle({ force: true });
-await host.emit("session_shutdown", { sessionId: "demo" });
-
-const index = await readFile(path.join(root, "MEMORY.md"), "utf8").catch(() => "");
-console.log("\n--- MEMORY.md ---\n" + (index || "(empty)"));
-
-// Round-trip the host wiring install against a temp settings file.
-const configPath = path.join(root, "settings.json");
-const res = await connect(piAdapter, { configPath, apply: true });
-console.log("\n[connect] verify ok:", res.verify?.ok, res.verify?.problems ?? []);
-
-dispose();
-
-if (useFake) {
- if (!/green tea|Preferred drink/.test(index)) {
- console.error("SMOKE FAIL: expected a preference memory to be written");
- process.exit(1);
- }
- if (!/Reply tone/.test(index)) {
- console.error("SMOKE FAIL: expected the subagent's second memory to be written");
- process.exit(1);
- }
- // Privacy: the high-risk secret in the transcript must never reach disk.
- if (new RegExp("sk" + "-ABCDEFabcdef").test(index)) {
- console.error("SMOKE FAIL: a high-risk secret was persisted");
- process.exit(1);
- }
-}
-console.log("\nOK");
diff --git a/examples/shared/fake-model.mjs b/examples/shared/fake-model.mjs
deleted file mode 100644
index 95806bc..0000000
--- a/examples/shared/fake-model.mjs
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * Deterministic offline canonical model for example smoke tests.
- *
- * It plays the part of the extraction subagent without network or API keys. The
- * SDK sees only @memflywheel/model's canonical shape: messages, tool definitions,
- * and structured tool call inputs.
- */
-
-import { serializeMemoryFile } from "@memflywheel/core";
-
-function toolCall(id, name, input) {
- return { id, name, input };
-}
-
-function writeMemoryArgs(input) {
- return {
- filePath: `${input.type}/${input.filename}`,
- content: serializeMemoryFile(input),
- };
-}
-
-export function createFakeModel() {
- let round = 0;
- return {
- async complete(_req) {
- round += 1;
-
- if (round === 1) {
- return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [toolCall("c-list", "glob", { pattern: "**/*.md" })],
- },
- finishReason: "tool-calls",
- };
- }
-
- if (round === 2) {
- return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [
- toolCall(
- "c-save-1",
- "write",
- writeMemoryArgs({
- type: "preference",
- filename: "preferred-drink.md",
- name: "Preferred drink",
- description: "User's go-to beverage",
- body: "The user prefers green tea over coffee.",
- }),
- ),
- toolCall(
- "c-save-2",
- "write",
- writeMemoryArgs({
- type: "style",
- filename: "reply-tone.md",
- name: "Reply tone",
- description: "How the user likes replies",
- body: "The user appreciates short, direct acknowledgements.",
- }),
- ),
- ],
- },
- finishReason: "tool-calls",
- };
- }
-
- return {
- message: {
- role: "assistant",
- content: "No further memories worth saving this round.",
- },
- finishReason: "stop",
- };
- },
- };
-}
diff --git a/examples/shared/transcript.mjs b/examples/shared/transcript.mjs
deleted file mode 100644
index d215619..0000000
--- a/examples/shared/transcript.mjs
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * A small sample turn the examples feed to onTurnEnd.
- *
- * The user volunteers a durable preference (worth remembering) and, separately,
- * a high-risk secret (an API key) that must NOT be persisted. The extraction
- * subagent is expected to save the preference and decline the secret.
- */
-const sampleApiKey = "sk" + "-ABCDEFabcdef0123456789ABCDEFabcdef0123456789ABCD";
-
-export const transcript = [
- {
- role: "user",
- text:
- "Just so you know, I always drink green tea, never coffee. " +
- `Also my OpenAI key is ${sampleApiKey} — keep it handy.`,
- },
- { role: "assistant", text: "Got it — I'll remember you prefer green tea." },
-];
diff --git a/package.json b/package.json
index 91e5cfc..270bb06 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "memflywheel",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"description": "File-native long-term memory layer for AI agents.",
"license": "Apache-2.0",
@@ -12,7 +12,7 @@
"typescript"
],
"engines": {
- "node": ">=22.13"
+ "node": ">=22.19"
},
"repository": {
"type": "git",
@@ -36,6 +36,9 @@
"publish:npm": "node scripts/publish-npm.mjs"
},
"devDependencies": {
+ "@memflywheel/embeddings": "workspace:*",
+ "@memflywheel/sdk": "workspace:*",
+ "@memflywheel/skills": "workspace:*",
"@types/node": "^22.20.1",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
diff --git a/packages/adapters/NOTICE b/packages/adapters/NOTICE
index c590cea..1708093 100644
--- a/packages/adapters/NOTICE
+++ b/packages/adapters/NOTICE
@@ -2,6 +2,10 @@ MemFlywheel
Copyright 2026 iFLYTEK CO., LTD.
This product includes software developed at:
+- proper-lockfile (https://github.com/moxystudio/node-proper-lockfile), licensed under MIT License
+- graceful-fs (https://github.com/isaacs/node-graceful-fs), licensed under ISC License
+- retry (https://github.com/tim-kos/node-retry), licensed under MIT License
+- signal-exit (https://github.com/tapjs/signal-exit), licensed under ISC License
- TypeBox (https://github.com/sinclairzx81/typebox), licensed under MIT License
- TypeScript (https://www.typescriptlang.org/), licensed under Apache License 2.0
- DefinitelyTyped Node.js type definitions (https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node), licensed under MIT License
diff --git a/packages/adapters/README.md b/packages/adapters/README.md
index 46a4558..39d58ee 100644
--- a/packages/adapters/README.md
+++ b/packages/adapters/README.md
@@ -1,20 +1,21 @@
# @iflytekopensource/adapters
-Host lifecycle mappings for MemFlywheel. Each adapter translates a host's lifecycle
-events — session start, prompt assembly, turn end, idle/scheduled — onto a
-`MemFlywheel`'s hooks. Adapters contain **no memory logic**: they are pure event
-translation plus a real, round-trippable install of the host-side wiring.
+Host lifecycle mappings and native model bindings for MemFlywheel. Each adapter
+translates host lifecycle events onto MemFlywheel hooks and resolves the host's
+active model into a `pi-ai` stream. Core memory semantics remain in the bundled
+MemFlywheel Core and SDK layers.
-Zero runtime dependencies (Node stdlib + TypeScript only).
+The package installs Pi Agent Core, `pi-ai`, and `proper-lockfile` as runtime
+dependencies.
## Built-in adapters
-| id | host | session start | prompt build | turn end | idle/scheduled | integration |
-| ---------- | --------- | -------------------- | ---------------- | ------------------- | ------------------ | ----------- |
-| `pi` | Pi kernel | `session:ensure` | `turn:build` | `agent_end` | `learning:idle` | real |
-| `hermes` | Hermes | `on_session_start` | `pre_llm_call` | `post_llm_call` | `on_session_end` | real |
-| `openclaw` | OpenClaw | `before_agent_start` | `context:inject` | `agent_end` | `idle:watch` | real |
-| `opencode` | OpenCode | `session.init` | `message.build` | `response.complete` | `timer.background` | real |
+| id | host | prompt recall | turn end | session end | integration |
+| ---------- | -------- | ------------------------------------ | --------------------------------------------- | ------------------ | ----------- |
+| `pi` | Pi | `context` | `agent_end` | `session_shutdown` | real |
+| `hermes` | Hermes | `prefetch` | `sync_turn` | `on_session_end` | real |
+| `openclaw` | OpenClaw | `before_prompt_build` | `agent_end` | `session_end` | real |
+| `opencode` | OpenCode | `experimental.chat.system.transform` | `experimental.text.complete` / `session.idle` | `session.deleted` | real |
`@iflytekopensource/adapters` owns the shared host adapter/runtime layer. Host-specific
install shape still differs: Pi, OpenCode, and OpenClaw can load package
@@ -24,16 +25,12 @@ install its Python `MemoryProvider`, config wiring, and skill mirror.
- **`pi`** — real: `@iflytekopensource/adapters` is a Pi package. Its
`package.json` declares `pi.extensions`, and Pi installs it with
`pi install npm:@iflytekopensource/adapters`.
- `session:ensure` → `onSessionStart`; per-turn assembly → `onPromptBuild` (the
- scribe's `systemPrompt` merges into the per-session system prompt and
- `preludePrompt` is prepended to the prelude list); `agent_end` →
- `onTurnEnd` (fire-and-forget); learning-loop idle tick → `onIdle`.
+ `context` → `onPromptBuild`; `agent_end` → `onTurnEnd`; and
+ `session_shutdown` → `onSessionEnd`.
- **`hermes`** — real: `@iflytekopensource/hermes` installs a Hermes
`MemoryProvider`, and its bridge imports `@iflytekopensource/adapters` for the
- shared runtime. `on_session_start` → `onSessionStart`; `pre_llm_call` →
- `onPromptBuild` (inject prelude as `{"context": ...}`); `post_llm_call` →
- `onTurnEnd` (reads `user_message` + `assistant_response`, or an explicit
- `transcript`); `on_session_end` → `onIdle`.
+ shared runtime. `prefetch` builds recall context, `sync_turn` runs the
+ write-side lifecycle, and session end coordinates idle consolidation.
Each adapter declares a `defaultConfigRelPath` (the host config under `$HOME`) and
an `integrationNote` describing how the host actually consumes the scribe.
@@ -141,34 +138,25 @@ Install/verify/doctor come for free.
## Direct integration: `createMemFlywheelHarnessRuntime`
-An adapter contains no memory or provider-specific LLM logic. To make a host
-work out of the box, expose a host-owned `CanonicalModelCompletion` or a
-`HostHarnessPort` and pass it to `createMemFlywheelHarnessRuntime`. That builds
-the SDK default extraction AND dream consolidation subagents (default prompts +
-ordinary file tools) on top of the single canonical model channel, assembles a real
-`createMemFlywheel`, and returns an adapter-ready `MemFlywheel` plus the underlying
-SDK scribe for explicit ops. One channel drives both subagents:
+An adapter contains no memory loop. It resolves the host's active model into a
+pi-ai `Model` + `StreamFn` and exposes lifecycle events through `HostHarnessPort`.
+`createMemFlywheelHarnessRuntime` then builds Extraction, Dream, and Skill
+Evolution on the single Pi Agent Core runner.
```ts
-import { createMemFlywheelHarnessRuntime, hermesAdapter } from "@iflytekopensource/adapters";
+import { createMemFlywheelHarnessRuntime } from "@iflytekopensource/adapters";
-// Host-owned model channel. The host owns auth, transport, policy, and lifecycle.
-const model = {
- complete: (req) => ctx.llm.completeWithTools(req),
-};
-
-const { scribe, sdk } = createMemFlywheelHarnessRuntime({ model });
-const dispose = hermesAdapter.attach(scribe, host); // session/prompt/turn-end/idle
+const { scribe, sdk } = createMemFlywheelHarnessRuntime({ port });
```
Pi phase-1 native integration uses a host port:
```ts
import { createMemFlywheelHarnessRuntime, createPiHarnessPort } from "@iflytekopensource/adapters";
-import { completeSimple } from "@earendil-works/pi-ai/compat";
+import { streamSimple } from "@earendil-works/pi-ai/compat";
export default function memFlywheelExtension(pi) {
- const port = createPiHarnessPort(pi, { completeSimple });
+ const port = createPiHarnessPort(pi, { streamSimple });
const runtime = createMemFlywheelHarnessRuntime({ port });
return runtime.dispose;
}
@@ -208,7 +196,7 @@ learned-skill store:
```ts
const { scribe } = createMemFlywheelHarnessRuntime({
- model,
+ port,
learnedSkills: {
skillsRoot: "/path/to/skills",
checkpointRoot: "/path/to/.skill-checkpoints",
@@ -219,7 +207,7 @@ const { scribe } = createMemFlywheelHarnessRuntime({
});
```
-- With `model` or `port`: real semantic extraction AND dream consolidation run
+- With `resolveModel` or `port`: real semantic extraction AND dream consolidation run
as tool-calling subagents on the **host's own model**, writing memory files directly.
- With `learnedSkills`: the bridge creates a learned-skill store, recall
provider, and `runSkillEvolutionAgent`; turn-end can run extraction -> skill
@@ -228,15 +216,15 @@ const { scribe } = createMemFlywheelHarnessRuntime({
routes through the same SDK prompt context.
- With custom `learningLoop.skillEvolution`: hosts may replace the default
learned-skill runner while keeping SDK gate/dream coordination.
-- Without `model`/`port` and without an explicit `agent`: construction fails
+- Without `resolveModel`/`port` and without an explicit `agent`: construction fails
unless `mode: "recall-only"` is set explicitly. Recall-only injects memory on
prompt build, turns never extract, and dream runs only its deterministic structural pre-pass.
- The adapter-facing `onSessionEnd` runs a final agent-end sweep (extracting any
not-yet-processed messages) before dropping the session.
-Hosts with no in-process model-call API (for example a hook-only plugin surface)
-must either run recall-only or expose a real canonical model port through a
-sidecar/upstream host API. MemFlywheel does not parse text as fake tool calls.
+Hosts with no in-process model-call API must either run recall-only or expose a
+real pi-ai stream through a sidecar/upstream host API. MemFlywheel does not parse
+text as fake tool calls.
```ts
const { scribe } = createMemFlywheelHarnessRuntime({ mode: "recall-only" });
diff --git a/packages/adapters/THIRD_PARTY_LICENSES b/packages/adapters/THIRD_PARTY_LICENSES
index aa7c6fd..accfbf1 100644
--- a/packages/adapters/THIRD_PARTY_LICENSES
+++ b/packages/adapters/THIRD_PARTY_LICENSES
@@ -1,12 +1,33 @@
Third-Party Licenses
====================
-This file lists third-party npm packages used by the MemFlywheel source
-workspace for build, test, and type-checking. The MemFlywheel packages
-themselves are licensed under the repository LICENSE file.
+This file lists third-party npm packages used at runtime or for source
+validation, type-checking, and builds. The MemFlywheel packages themselves are
+licensed under the repository LICENSE file.
-Published MemFlywheel packages do not bundle third-party runtime libraries.
-The packages below are used for source validation, type-checking, and builds.
+Package: proper-lockfile
+Version: 4.1.2
+Homepage: https://github.com/moxystudio/node-proper-lockfile
+License: MIT
+Copyright: Copyright (c) 2018 Made With MOXY Lda
+
+Package: graceful-fs
+Version: 4.2.11
+Homepage: https://github.com/isaacs/node-graceful-fs
+License: ISC
+Copyright: Copyright (c) 2011-2022 Isaac Z. Schlueter, Ben Noordhuis, and Contributors
+
+Package: retry
+Version: 0.12.0
+Homepage: https://github.com/tim-kos/node-retry
+License: MIT
+Copyright: Copyright (c) 2011 Tim Koschützki and Felix Geisendörfer
+
+Package: signal-exit
+Version: 3.0.7
+Homepage: https://github.com/tapjs/signal-exit
+License: ISC
+Copyright: Copyright (c) 2015 Contributors
Package: typebox
Version: 1.3.1
diff --git a/packages/adapters/openclaw-extension/index.mjs b/packages/adapters/openclaw-extension/index.mjs
index b1d4f5c..d3e8d29 100644
--- a/packages/adapters/openclaw-extension/index.mjs
+++ b/packages/adapters/openclaw-extension/index.mjs
@@ -1,5 +1,10 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
-import { createOpenClawPluginRuntime } from "../dist/index.js";
+import {
+ completeWithPreparedSimpleCompletionModel,
+ prepareSimpleCompletionModelForAgent,
+ resolveDefaultAgentId,
+} from "openclaw/plugin-sdk/agent-runtime";
+import { createOpenClawPluginRuntime, openClawHostMemoryPaths } from "../dist/index.js";
export default definePluginEntry({
id: "memflywheel",
@@ -7,7 +12,16 @@ export default definePluginEntry({
description: "File-native long-term memory and learned skills for OpenClaw.",
kind: "memory",
register(api) {
- const dispose = createOpenClawPluginRuntime(api);
+ const nativeModelRuntime = {
+ currentConfig: () => api.runtime.config.current(),
+ resolveDefaultAgentId,
+ prepareForAgent: prepareSimpleCompletionModelForAgent,
+ completePrepared: completeWithPreparedSimpleCompletionModel,
+ };
+ const dispose = createOpenClawPluginRuntime(api, {
+ nativeModelRuntime,
+ protectedMemoryPaths: openClawHostMemoryPaths(nativeModelRuntime.currentConfig()),
+ });
api.lifecycle?.registerRuntimeLifecycle?.({
id: "memflywheel-runtime",
cleanup: () => dispose(),
diff --git a/packages/adapters/openclaw.plugin.json b/packages/adapters/openclaw.plugin.json
index 7528636..0426d52 100644
--- a/packages/adapters/openclaw.plugin.json
+++ b/packages/adapters/openclaw.plugin.json
@@ -1,8 +1,10 @@
{
"id": "memflywheel",
"activation": {
- "onStartup": false
+ "onStartup": true,
+ "onCapabilities": ["hook"]
},
+ "hooks": ["before_prompt_build", "before_tool_call", "agent_end", "session_end", "gateway_stop"],
"kind": "memory",
"contracts": {
"tools": []
diff --git a/packages/adapters/package.json b/packages/adapters/package.json
index 991550f..70dfb55 100644
--- a/packages/adapters/package.json
+++ b/packages/adapters/package.json
@@ -1,6 +1,6 @@
{
"name": "@iflytekopensource/adapters",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Host lifecycle adapters for wiring MemFlywheel into agent runtimes.",
"license": "Apache-2.0",
"type": "module",
@@ -54,36 +54,34 @@
"./openclaw-extension/index.mjs"
],
"compat": {
- "pluginApi": "2026.6.10"
+ "pluginApi": ">=2026.6.10"
}
},
"publishConfig": {
"access": "public"
},
"engines": {
- "node": ">=22.13"
+ "node": ">=22.19"
},
"scripts": {
- "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -b && tsup src/index.ts --format esm --target node22 --dts --dts-resolve --out-dir dist --no-splitting --clean false --tsconfig tsconfig.bundle.json --external @earendil-works/pi-ai --external openclaw --external 'openclaw/*'",
+ "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -b && tsup src/index.ts --format esm --target node22 --dts --dts-resolve --out-dir dist --no-splitting --clean false --tsconfig tsconfig.bundle.json --external @earendil-works/pi-ai --external @earendil-works/pi-agent-core --external proper-lockfile --external openclaw --external 'openclaw/*'",
"test": "pnpm run build && node --test \"dist/**/*.test.js\" \"pi-extension/**/*.test.mjs\"",
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
+ "dependencies": {
+ "@earendil-works/pi-agent-core": "^0.82.1",
+ "@earendil-works/pi-ai": "^0.82.1",
+ "proper-lockfile": "^4.1.2"
+ },
"devDependencies": {
- "@memflywheel/model": "workspace:*",
- "@memflywheel/sdk": "workspace:*",
- "@memflywheel/skills": "workspace:*",
"@types/node": "^22.20.1",
"tsup": "^8.5.1",
"typescript": "^5.5.4"
},
"peerDependencies": {
- "@earendil-works/pi-ai": "*",
"openclaw": "*"
},
"peerDependenciesMeta": {
- "@earendil-works/pi-ai": {
- "optional": true
- },
"openclaw": {
"optional": true
}
diff --git a/packages/adapters/pi-extension/index.mjs b/packages/adapters/pi-extension/index.mjs
index 95b3892..8f7a5c1 100644
--- a/packages/adapters/pi-extension/index.mjs
+++ b/packages/adapters/pi-extension/index.mjs
@@ -1,6 +1,6 @@
import { join } from "node:path";
-import { completeSimple } from "@earendil-works/pi-ai/compat";
+import { streamSimple } from "@earendil-works/pi-ai/compat";
import { createMemFlywheelHarnessRuntime, createPiHarnessPort } from "../dist/index.js";
import { resolveMemFlywheelRoot, resolvePiAgentDir, syncLearnedSkillsToPi } from "./sync.mjs";
@@ -9,7 +9,7 @@ export default function memFlywheelExtension(pi) {
const root = resolveMemFlywheelRoot(piAgentDir);
const syncSkills = () => syncLearnedSkillsToPi({ root, piAgentDir });
const port = createPiHarnessPort(pi, {
- completeSimple,
+ streamSimple,
afterPromptBuild: syncSkills,
afterTurnEnd: syncSkills,
afterSessionEnd: syncSkills,
diff --git a/packages/adapters/src/harness-port.ts b/packages/adapters/src/harness-port.ts
index 7dd7517..adc96ef 100644
--- a/packages/adapters/src/harness-port.ts
+++ b/packages/adapters/src/harness-port.ts
@@ -1,13 +1,20 @@
-import type { CanonicalModelCompletion, CanonicalModelMessage } from "@memflywheel/model";
+import type { ResolvePiAgentModel } from "@memflywheel/sdk";
+
+export interface HostToolCall {
+ id: string;
+ name: string;
+ input: unknown;
+}
+
+export interface HostMessage {
+ role: "user" | "assistant" | "tool";
+ content?: string | null;
+ toolCalls?: HostToolCall[];
+ toolCallId?: string;
+}
export type HostCapability =
- | "prompt-build"
- | "turn-end"
- | "session-end"
- | "idle"
- | "single-tool-completion"
- | "agentic-tool-loop"
- | "tool-trajectory";
+ "prompt-build" | "turn-end" | "session-end" | "idle" | "agentic-tool-loop" | "tool-trajectory";
export type HostIntegrationMode = "none" | "recall-only" | "memory-loop" | "skill-loop";
@@ -26,7 +33,7 @@ export interface HostPromptBuildResult {
export interface HostTurnEndEvent {
sessionId: string;
- messages: CanonicalModelMessage[];
+ messages: HostMessage[];
}
export interface HostSessionEvent {
@@ -65,7 +72,7 @@ export interface HostHarnessPort {
readonly name: string;
readonly capabilities: ReadonlySet;
readonly lifecycle: HostLifecyclePort;
- readonly model: CanonicalModelCompletion;
+ readonly resolveModel: ResolvePiAgentModel;
readonly telemetry?: HostTelemetryPort;
}
diff --git a/packages/adapters/src/hermes.ts b/packages/adapters/src/hermes.ts
index 3cc9636..9c503e2 100644
--- a/packages/adapters/src/hermes.ts
+++ b/packages/adapters/src/hermes.ts
@@ -1,8 +1,8 @@
/**
* Hermes adapter (real integration).
*
- * A Hermes plugin's `register(ctx)` maps the host LLM facade into the canonical
- * model protocol and binds the scribe to Hermes' real hooks:
+ * A Hermes plugin's `register(ctx)` exposes its model transport as a pi-ai
+ * StreamFn and binds the scribe to Hermes' real hooks:
*
* - `on_session_start` → onSessionStart
* - `pre_llm_call` → onPromptBuild (inject prelude as {"context": ...} into
@@ -48,7 +48,7 @@ export const hermesAdapter: HostAdapter = makeAdapter({
lifecycle,
defaultConfigRelPath: ".hermes/config.json",
integrationNote:
- "Real integration path: a Hermes plugin must expose `ctx.llm.completeWithTools` as a canonical model; the plugin config block carries the wiring marker.",
+ "Real integration path: the Hermes MemoryProvider bridges call_llm into the shared Pi Agent Core runner; the plugin config block carries the wiring marker.",
translators: {
sessionId: (payload) =>
readString(payload, "session_id") ||
diff --git a/packages/adapters/src/host-memflywheel.test.ts b/packages/adapters/src/host-memflywheel.test.ts
index de9e773..3a5ba4f 100644
--- a/packages/adapters/src/host-memflywheel.test.ts
+++ b/packages/adapters/src/host-memflywheel.test.ts
@@ -15,7 +15,11 @@ import {
type ExtractionAgentRunner,
type MemoryType,
} from "@memflywheel/sdk";
-import type { CanonicalModelCompletion, CanonicalModelResponse } from "@memflywheel/model";
+import {
+ resolveTestModel,
+ type TestModelCompletion,
+ type TestModelResponse,
+} from "./model-test-support.test.js";
import type {
HostHarnessPort,
HostPromptBuildEvent,
@@ -31,7 +35,7 @@ import { createFakeHost, tempDir } from "./test-helpers.js";
const flush = () => new Promise((r) => setImmediate(r));
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
-const STOP: CanonicalModelResponse = {
+const STOP: TestModelResponse = {
message: { role: "assistant", content: "done" },
finishReason: "stop",
};
@@ -129,7 +133,7 @@ function workflowAgent(): ExtractionAgentRunner {
* memory via the ordinary write tool; on every subsequent round it stops. This is
* deterministic and offline — no network, no key.
*/
-function savingModel(): CanonicalModelCompletion {
+function savingModel(): TestModelCompletion {
let calls = 0;
return {
complete: async () => {
@@ -164,7 +168,7 @@ function toolCall(id: string, name: string, args: unknown) {
return { id, name, input: args };
}
-function learnedSkillLoopModel(): CanonicalModelCompletion {
+function learnedSkillLoopModel(): TestModelCompletion {
let extractionStep = 0;
let skillStep = 0;
let dreamStep = 0;
@@ -270,7 +274,7 @@ function section(text: string, heading: string, nextHeading: string): string {
}
/** A subagent that declines: it calls no tools and replies with one sentence. */
-const decliningModel: CanonicalModelCompletion = { complete: async () => STOP };
+const decliningModel: TestModelCompletion = { complete: async () => STOP };
async function writeManyMemories(root: string, count: number): Promise {
const dir = path.join(root, "preference");
@@ -346,9 +350,12 @@ async function withEnv(values: Record, run: () => Promise)
}
}
-test("createMemFlywheelHarnessRuntime with a canonical model extracts and writes a memory end-to-end", async () => {
+test("createMemFlywheelHarnessRuntime with a Pi model resolver extracts and writes memory", async () => {
const root = await tempDir();
- const { scribe } = createMemFlywheelHarnessRuntime({ model: savingModel(), root });
+ const { scribe } = createMemFlywheelHarnessRuntime({
+ resolveModel: resolveTestModel(savingModel()),
+ root,
+ });
await scribe.onSessionStart({ sessionId: "s1" });
// Await the turn-end so the full lock→agent-loop→write chain completes.
@@ -366,9 +373,30 @@ test("createMemFlywheelHarnessRuntime with a canonical model extracts and writes
assert.match(file, /green tea/);
});
+test("adapter lifecycle evaluates and runs the Dream gate after session end", async () => {
+ const root = await tempDir();
+ const { scribe } = createMemFlywheelHarnessRuntime({
+ resolveModel: resolveTestModel(decliningModel),
+ root,
+ });
+
+ for (let index = 0; index < 5; index += 1) {
+ const sessionId = `dream-session-${index}`;
+ await scribe.onSessionStart({ sessionId });
+ await scribe.onSessionEnd({ sessionId });
+ }
+
+ const state = JSON.parse(await readFile(path.join(root, ".dream-state.json"), "utf8"));
+ assert.equal(state.sessionsSince, 0);
+ assert.equal(typeof state.lastConsolidatedAt, "number");
+});
+
test("attach drives a real end-to-end extraction through host events", async () => {
const root = await tempDir();
- const { scribe } = createMemFlywheelHarnessRuntime({ model: savingModel(), root });
+ const { scribe } = createMemFlywheelHarnessRuntime({
+ resolveModel: resolveTestModel(savingModel()),
+ root,
+ });
const host = createFakeHost();
const dispose = piAdapter.attach(scribe, host);
@@ -420,7 +448,7 @@ test("host harness folds telemetry tool calls into turn-end messages", async ()
const port: HostHarnessPort = {
name: "fake",
capabilities: new Set(["prompt-build", "turn-end", "session-end", "agentic-tool-loop"]),
- model: decliningModel,
+ resolveModel: resolveTestModel(decliningModel),
lifecycle: {
onPromptBuild(handler) {
promptBuild = handler;
@@ -482,7 +510,10 @@ test("host harness folds telemetry tool calls into turn-end messages", async ()
test("createMemFlywheelHarnessRuntime prompt build returns the two recall segments", async () => {
const root = await tempDir();
- const { scribe } = createMemFlywheelHarnessRuntime({ model: savingModel(), root });
+ const { scribe } = createMemFlywheelHarnessRuntime({
+ resolveModel: resolveTestModel(savingModel()),
+ root,
+ });
const ctx = await scribe.onPromptBuild({ sessionId: "s1" });
assert.equal(ctx.enabled, true);
@@ -504,7 +535,10 @@ test("createMemFlywheelHarnessRuntime enables index pre-recall from embedding en
MEMFLYWHEEL_MEMORY_INDEX_RETRIEVAL_LIMIT: "3",
},
async () => {
- const { scribe } = createMemFlywheelHarnessRuntime({ model: decliningModel, root });
+ const { scribe } = createMemFlywheelHarnessRuntime({
+ resolveModel: resolveTestModel(decliningModel),
+ root,
+ });
const ctx = await scribe.onPromptBuild({
sessionId: "s1",
@@ -598,7 +632,7 @@ test("createMemFlywheelHarnessRuntime learnedSkills assembly runs extraction, sk
const skillsRoot = path.join(root, "skills");
const { scribe } = createMemFlywheelHarnessRuntime({
root: path.join(root, "memory"),
- model: learnedSkillLoopModel(),
+ resolveModel: resolveTestModel(learnedSkillLoopModel()),
learnedSkills: {
skillsRoot,
checkpointRoot: path.join(root, ".skill-checkpoints"),
@@ -657,7 +691,7 @@ test("createMemFlywheelHarnessRuntime learnedSkills caps prompt packet context",
const root = await tempDir();
let captured = "";
let extractionStep = 0;
- const model: CanonicalModelCompletion = {
+ const model: TestModelCompletion = {
complete: async (req) => {
const system = req.messages.find((message) => message.role === "system")?.content ?? "";
if (system.includes("memory extraction engine")) {
@@ -693,7 +727,7 @@ test("createMemFlywheelHarnessRuntime learnedSkills caps prompt packet context",
};
const { scribe } = createMemFlywheelHarnessRuntime({
root: path.join(root, "memory"),
- model,
+ resolveModel: resolveTestModel(model),
learnedSkills: {
skillsRoot: path.join(root, "skills"),
checkpointRoot: path.join(root, ".skill-checkpoints"),
@@ -737,7 +771,7 @@ test("createMemFlywheelHarnessRuntime learnedSkills caps prompt packet context",
test("createMemFlywheelHarnessRuntime requires explicit recall-only mode when no model is present", async () => {
const root = await tempDir();
- assert.throws(() => createMemFlywheelHarnessRuntime({ root }), /requires a canonical model/);
+ assert.throws(() => createMemFlywheelHarnessRuntime({ root }), /requires a Pi model resolver/);
const { scribe } = createMemFlywheelHarnessRuntime({ root, mode: "recall-only" });
@@ -759,7 +793,10 @@ test("createMemFlywheelHarnessRuntime requires explicit recall-only mode when no
test("createMemFlywheelHarnessRuntime decline (no tool calls) writes nothing", async () => {
const root = await tempDir();
- const { scribe } = createMemFlywheelHarnessRuntime({ model: decliningModel, root });
+ const { scribe } = createMemFlywheelHarnessRuntime({
+ resolveModel: resolveTestModel(decliningModel),
+ root,
+ });
await scribe.onSessionStart({ sessionId: "s1" });
await scribe.onTurnEnd({
@@ -776,7 +813,7 @@ test("createMemFlywheelHarnessRuntime decline (no tool calls) writes nothing", a
test("disabled scribe makes every hook a no-op", async () => {
const root = await tempDir();
const { scribe } = createMemFlywheelHarnessRuntime({
- model: savingModel(),
+ resolveModel: resolveTestModel(savingModel()),
root,
enabled: false,
});
diff --git a/packages/adapters/src/host-memflywheel.ts b/packages/adapters/src/host-memflywheel.ts
index 02d2ba0..104666b 100644
--- a/packages/adapters/src/host-memflywheel.ts
+++ b/packages/adapters/src/host-memflywheel.ts
@@ -1,15 +1,15 @@
/**
- * Host harness runtime — turn a host-owned canonical model channel into a
+ * Host harness runtime — turn a host-owned pi-ai model binding into a
* fully-wired memory scribe the adapters can drive directly.
*
* Both memory subagents are tool-calling loops: the SDK ships
* `createExtractionAgentRunner({ model })` and `createDreamAgentRunner({ model })`,
* loops that call core's memory-write tools to write files directly. The only
- * model contract here is @memflywheel/model's canonical protocol; provider wire
- * shapes and host runtimes are mapped before they enter this file.
+ * model contract is Pi's native Model + StreamFn; provider wire shapes stay in
+ * the host adapters.
*
* Nothing here owns provider auth or performs model transport by itself. The
- * host supplies the canonical model object, usually via a HostHarnessPort.
+ * host resolves the active model binding, usually via a HostHarnessPort.
*/
import {
@@ -28,19 +28,15 @@ import {
createExtractionAgentRunner,
createMemFlywheel,
runSkillEvolutionAgent,
+ type ResolvePiAgentModel,
} from "@memflywheel/sdk";
import {
type LearnedSkillStoreCheckpoint,
createLearnedSkillRecallProvider,
createLearnedSkillStore,
} from "@memflywheel/skills";
-import type {
- CanonicalModelCompletion,
- CanonicalModelMessage,
- CanonicalToolCall,
- OpenAIEmbeddingsModelConfig,
-} from "@memflywheel/model";
-import { createOpenAIEmbeddingsModel } from "@memflywheel/model";
+import type { OpenAIEmbeddingsModelConfig } from "@memflywheel/embeddings";
+import { createOpenAIEmbeddingsModel } from "@memflywheel/embeddings";
import type { MemFlywheel, MemFlywheelContext, MemFlywheelMessage } from "./adapter.js";
import {
@@ -49,6 +45,8 @@ import {
type HostToolCallEvent,
type HostToolResultEvent,
type HostHarnessPort,
+ type HostMessage,
+ type HostToolCall,
type HostIntegrationMode,
} from "./harness-port.js";
@@ -62,7 +60,7 @@ export type {
SkillPreludeBuilder,
SkillRecallProvider,
} from "@memflywheel/sdk";
-export type { CanonicalModelCompletion } from "@memflywheel/model";
+export type { ResolvePiAgentModel } from "@memflywheel/sdk";
export interface HostLearnedSkillEvolutionInput {
sessionId: string;
@@ -98,15 +96,14 @@ export type MemFlywheelHarnessMode = "native" | "recall-only";
/** Options for {@link createMemFlywheelHarnessRuntime}. */
export interface MemFlywheelHarnessRuntimeOptions {
/**
- * Optional host port. Phase 1 uses the port's canonical model; lifecycle
+ * Optional host port. The runtime uses its model resolver and lifecycle
* binding remains explicit so existing adapter attach tests stay focused.
*/
port?: HostHarnessPort;
/**
- * Host-owned canonical model channel. Drives BOTH subagents — extraction and
- * dream consolidation — which write memories directly via core's tools.
+ * Host-owned pi-ai model resolver. Drives extraction, dream, and skill evolution.
*/
- model?: CanonicalModelCompletion;
+ resolveModel?: ResolvePiAgentModel;
/** Explicit runtime mode. No implicit recall-only fallback. */
mode?: MemFlywheelHarnessMode;
/** Memory root override. Falls back to MEMFLYWHEEL_HOME / OS data dir. */
@@ -252,8 +249,8 @@ function toExtractionMessages(messages: AnyTurnMessage[]): ExtractionMessage[] {
return out;
}
-export function canonicalMessagesToMemFlywheelMessages(
- messages: readonly CanonicalModelMessage[],
+export function hostMessagesToMemFlywheelMessages(
+ messages: readonly HostMessage[],
): MemFlywheelMessage[] {
const outputs = new Map();
for (const message of messages) {
@@ -282,7 +279,7 @@ export function canonicalMessagesToMemFlywheelMessages(
return out;
}
-interface BufferedToolCall extends CanonicalToolCall {
+interface BufferedToolCall extends HostToolCall {
output?: unknown;
}
@@ -300,7 +297,7 @@ function contentFromToolOutput(output: unknown): string | null {
}
}
-function existingToolCallIds(messages: readonly CanonicalModelMessage[]): Set {
+function existingToolCallIds(messages: readonly HostMessage[]): Set {
const ids = new Set();
for (const message of messages) {
for (const call of message.toolCalls ?? []) ids.add(call.id);
@@ -351,8 +348,8 @@ function recordToolResult(
function drainTelemetryMessages(
toolCallsBySession: Map>,
sessionId: string,
- baseMessages: readonly CanonicalModelMessage[],
-): CanonicalModelMessage[] {
+ baseMessages: readonly HostMessage[],
+): HostMessage[] {
const calls = toolCallsBySession.get(sessionKey(sessionId));
if (!calls || calls.size === 0) return [];
toolCallsBySession.delete(sessionKey(sessionId));
@@ -361,7 +358,7 @@ function drainTelemetryMessages(
const unique = [...calls.values()].filter((call) => !seen.has(call.id));
if (unique.length === 0) return [];
- const messages: CanonicalModelMessage[] = [
+ const messages: HostMessage[] = [
{
role: "assistant",
content: null,
@@ -403,7 +400,7 @@ export function attachMemFlywheelToHostPort(
);
await scribe.onTurnEnd({
sessionId: event.sessionId,
- messages: canonicalMessagesToMemFlywheelMessages([...event.messages, ...telemetryMessages]),
+ messages: hostMessagesToMemFlywheelMessages([...event.messages, ...telemetryMessages]),
});
}),
);
@@ -537,12 +534,15 @@ export function adaptSdkMemFlywheel(sdk: SdkMemFlywheel): MemFlywheelHarnessRunt
sessionId: string;
messages: MemFlywheelMessage[];
}): Promise {
- return sdk.onTurnEnd(input.sessionId, toExtractionMessages(input.messages));
+ const result = await sdk.onTurnEnd(input.sessionId, toExtractionMessages(input.messages));
+ await sdk.onIdle();
+ return result;
},
async onSessionEnd(input: { sessionId: string }): Promise {
// Final sweep over any messages not yet behind the cursor, then drop state.
await sdk.onAgentEnd(input.sessionId);
await sdk.onSessionEnd(input.sessionId);
+ await sdk.onIdle();
},
async onIdle(input?: { force?: boolean }): Promise {
await sdk.onIdle({ force: input?.force });
@@ -551,7 +551,7 @@ export function adaptSdkMemFlywheel(sdk: SdkMemFlywheel): MemFlywheelHarnessRunt
}
/**
- * Build a batteries-included scribe from a host's canonical model channel.
+ * Build a batteries-included scribe from a host's active pi-ai model binding.
*
* - With `model`: real semantic extraction + consolidation run as
* tool-calling subagents on the host's own model, writing memory files directly.
@@ -571,12 +571,12 @@ export function createMemFlywheelHarnessRuntime(
memoryIndexRetrieval,
learnedSkills,
} = options;
- const model = options.model ?? options.port?.model;
+ const resolveModel = options.resolveModel ?? options.port?.resolveModel;
const requestedMode = options.mode ?? "native";
- if (requestedMode !== "recall-only" && !model && !options.agent) {
+ if (requestedMode !== "recall-only" && !resolveModel && !options.agent) {
throw new Error(
- 'createMemFlywheelHarnessRuntime requires a canonical model or explicit extraction agent; pass mode:"recall-only" to disable extraction.',
+ 'createMemFlywheelHarnessRuntime requires a Pi model resolver or explicit extraction agent; pass mode:"recall-only" to disable extraction.',
);
}
if (options.port && requestedMode !== "recall-only") {
@@ -589,24 +589,24 @@ export function createMemFlywheelHarnessRuntime(
const agent =
options.agent ??
- (requestedMode === "recall-only" || !model
+ (requestedMode === "recall-only" || !resolveModel
? undefined
- : createExtractionAgentRunner({ model }));
+ : createExtractionAgentRunner({ resolveModel }));
let dreamRunner: DreamAgentRunner | undefined;
if (options.dreamRunner === null) {
dreamRunner = undefined;
} else if (options.dreamRunner) {
dreamRunner = options.dreamRunner;
- } else if (requestedMode !== "recall-only" && model) {
- dreamRunner = createDreamAgentRunner({ model });
+ } else if (requestedMode !== "recall-only" && resolveModel) {
+ dreamRunner = createDreamAgentRunner({ resolveModel });
}
let sdkSkillRecall = skillRecall;
let sdkLearningLoop = learningLoop;
if (learnedSkills) {
- if (!model) {
- throw new Error("learnedSkills requires a canonical model");
+ if (!resolveModel) {
+ throw new Error("learnedSkills requires a Pi model resolver");
}
if (options.port) {
requireHostCapabilities(options.port.name, options.port.capabilities, [
@@ -627,7 +627,7 @@ export function createMemFlywheelHarnessRuntime(
});
const assembledSkillEvolution = async (input: HostLearnedSkillEvolutionInput) =>
runSkillEvolutionAgent({
- model,
+ resolveModel,
store,
sessionId: input.sessionId,
reviewPacket: (learnedSkills.reviewPacket ?? defaultReviewPacket)(input),
diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts
index dd55dc6..872574b 100644
--- a/packages/adapters/src/index.ts
+++ b/packages/adapters/src/index.ts
@@ -52,6 +52,12 @@ export {
// Factory + translator helpers (for building custom adapters).
export { type AdapterSpec, makeAdapter, readString, normalizeMessages } from "./make-adapter.js";
+// Hermes installs its bridge worker outside the pnpm workspace. Re-export the
+// pi-ai stream primitive through this already-resolved adapter entrypoint so
+// the installed worker has one dependency boundary instead of reaching back
+// into workspace node_modules by package name.
+export { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
+
// Host harness port: stable host boundary + capability gates.
export {
type HostCapability,
@@ -59,6 +65,8 @@ export {
type Dispose,
type HostPromptBuildEvent,
type HostPromptBuildResult,
+ type HostToolCall,
+ type HostMessage,
type HostTurnEndEvent,
type HostSessionEvent,
type HostIdleEvent,
@@ -81,20 +89,19 @@ export {
type PiToolResultMessage,
type PiAssistantMessage,
type PiAgentMessage,
- type PiModelContext,
type PiModelAuthResult,
type PiExtensionContextLike,
type PiExtensionHandler,
type PiExtensionApiLike,
- type PiCompleteSimple,
+ type PiStreamSimple,
type PiSessionIdResolver,
- type CreatePiModelCompletionOptions,
+ type CreatePiAgentModelResolverOptions,
type CreatePiHarnessPortOptions,
type PiScribeLike,
- canonicalMessagesFromPi,
+ hostMessagesFromPi,
memScribeMessagesFromPi,
buildPiPromptInjection,
- createPiModelCompletion,
+ createPiAgentModelResolver,
attachPiScribe,
createPiHarnessPort,
} from "./pi-port.js";
@@ -105,30 +112,32 @@ export {
type OpenCodeHarnessPortOptions,
type OpenCodeHooks,
defaultOpenCodeMemFlywheelRoot,
- canonicalMessagesFromOpenCodeSessionMessages,
+ configureOpenCodeMemoryPermission,
+ hostMessagesFromOpenCodeSessionMessages,
+ createOpenCodeHostModel,
createOpenCodeHarnessPort,
createOpenCodePluginServer,
createOpenCodePluginServer as server,
} from "./opencode-port.js";
+export {
+ type OpenClawNativeModelSelection,
+ type OpenClawNativeModelRuntime,
+ createOpenClawHostModel,
+} from "./openclaw-native-model.js";
+
export {
type OpenClawApiLike,
type OpenClawHarnessPortOptions,
defaultOpenClawMemFlywheelRoot,
- canonicalMessagesFromOpenClawMessages,
+ hostMessagesFromOpenClawMessages,
createOpenClawHarnessPort,
registerOpenClawMemoryCapability,
+ registerOpenClawSingleWriterGuard,
+ openClawHostMemoryPaths,
createOpenClawPluginRuntime,
} from "./openclaw-port.js";
-export {
- type EnvLike,
- type OpenAICompatibleEnvModelOptions,
- type ResolvedOpenAICompatibleEnvModelConfig,
- resolveOpenAICompatibleEnvModelConfig,
- createOpenAICompatibleEnvModel,
-} from "./openai-env-model.js";
-
// Host-scribe bridge: wrap a canonical host model into a batteries-included scribe.
export {
type HostLearnedSkillEvolutionInput,
@@ -141,8 +150,8 @@ export {
type MemoryIndexRetrievalOptions,
type SkillPreludeBuilder,
type SkillRecallProvider,
- type CanonicalModelCompletion,
- canonicalMessagesToMemFlywheelMessages,
+ type ResolvePiAgentModel,
+ hostMessagesToMemFlywheelMessages,
attachMemFlywheelToHostPort,
createMemFlywheelHarnessRuntime,
adaptSdkMemFlywheel,
diff --git a/packages/adapters/src/model-test-support.test.ts b/packages/adapters/src/model-test-support.test.ts
new file mode 100644
index 0000000..6c1228e
--- /dev/null
+++ b/packages/adapters/src/model-test-support.test.ts
@@ -0,0 +1,129 @@
+import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
+import type { ResolvePiAgentModel } from "@memflywheel/sdk";
+
+export interface TestModelMessage {
+ role: "system" | "user" | "assistant" | "tool";
+ content?: string | null;
+ toolCalls?: Array<{ id: string; name: string; input: unknown }>;
+ toolCallId?: string;
+}
+
+export interface TestModelResponse {
+ message: TestModelMessage;
+ finishReason?: string;
+}
+
+export interface TestModelCompletion {
+ complete(request: {
+ messages: TestModelMessage[];
+ tools: Array<{ name: string; description: string; inputSchema: object }>;
+ signal?: AbortSignal;
+ }): Promise;
+}
+
+const model = {
+ id: "test",
+ name: "test",
+ api: "openai-completions" as const,
+ provider: "test",
+ baseUrl: "http://test.invalid",
+ reasoning: false,
+ input: ["text" as const],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 16_384,
+ maxTokens: 4_096,
+};
+
+const usage = {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+function assistant(response: TestModelResponse): AssistantMessage {
+ const content: AssistantMessage["content"] = [];
+ if (response.message.content) content.push({ type: "text", text: response.message.content });
+ for (const call of response.message.toolCalls ?? []) {
+ content.push({
+ type: "toolCall",
+ id: call.id,
+ name: call.name,
+ arguments: call.input as Record,
+ });
+ }
+ return {
+ role: "assistant",
+ content,
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage,
+ stopReason: response.message.toolCalls?.length ? "toolUse" : "stop",
+ timestamp: Date.now(),
+ };
+}
+
+/** Transitional test fixture only; production exposes no canonical completion adapter. */
+export function resolveTestModel(completion: TestModelCompletion): ResolvePiAgentModel {
+ return () => ({
+ model,
+ streamFn: async (_model, context, options) => {
+ const messages: TestModelMessage[] = [];
+ if (context.systemPrompt) messages.push({ role: "system", content: context.systemPrompt });
+ for (const message of context.messages) {
+ if (message.role === "user") {
+ messages.push({
+ role: "user",
+ content:
+ typeof message.content === "string"
+ ? message.content
+ : message.content
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
+ .join("\n"),
+ });
+ } else if (message.role === "assistant") {
+ messages.push({
+ role: "assistant",
+ content: message.content
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
+ .join("\n"),
+ toolCalls: message.content.flatMap((part) =>
+ part.type === "toolCall"
+ ? [{ id: part.id, name: part.name, input: part.arguments }]
+ : [],
+ ),
+ });
+ } else {
+ messages.push({
+ role: "tool",
+ toolCallId: message.toolCallId,
+ content: message.content
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
+ .join("\n"),
+ });
+ }
+ }
+ const response = assistant(
+ await completion.complete({
+ messages,
+ tools: (context.tools ?? []).map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.parameters as never,
+ })),
+ signal: options?.signal,
+ }),
+ );
+ const stream = createAssistantMessageEventStream();
+ stream.push({
+ type: "done",
+ reason: response.stopReason === "toolUse" ? "toolUse" : "stop",
+ message: response,
+ });
+ return stream;
+ },
+ });
+}
diff --git a/packages/adapters/src/openai-env-model.test.ts b/packages/adapters/src/openai-env-model.test.ts
deleted file mode 100644
index d2b3985..0000000
--- a/packages/adapters/src/openai-env-model.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { test } from "node:test";
-import assert from "node:assert/strict";
-
-import { resolveOpenAICompatibleEnvModelConfig } from "./openai-env-model.js";
-
-test("resolveOpenAICompatibleEnvModelConfig accepts proxy-style env aliases", () => {
- const config = resolveOpenAICompatibleEnvModelConfig({
- env: {
- CUSTOM_BASE_URL: " http://127.0.0.1:4891/v1/chat/completions ",
- CUSTOM_API_KEY: " proxy-key ",
- CUSTOM_MODEL: " deepseek-v4-flash ",
- },
- });
-
- assert.equal(config.endpoint, "http://127.0.0.1:4891/v1");
- assert.equal(config.apiKey, "proxy-key");
- assert.equal(config.model, "deepseek-v4-flash");
-});
-
-test("resolveOpenAICompatibleEnvModelConfig keeps MemFlywheel env precedence", () => {
- const config = resolveOpenAICompatibleEnvModelConfig({
- env: {
- MEMFLYWHEEL_LLM_ENDPOINT: "https://api.deepseek.com/v1",
- MEMFLYWHEEL_LLM_API_KEY: "mem-key",
- MEMFLYWHEEL_LLM_MODEL: "deepseek-v4-pro",
- OPENAI_BASE_URL: "https://api.openai.com/v1",
- OPENAI_API_KEY: "openai-key",
- OPENAI_MODEL: "gpt-5.5",
- },
- });
-
- assert.equal(config.endpoint, "https://api.deepseek.com/v1");
- assert.equal(config.apiKey, "mem-key");
- assert.equal(config.model, "deepseek-v4-pro");
-});
diff --git a/packages/adapters/src/openai-env-model.ts b/packages/adapters/src/openai-env-model.ts
deleted file mode 100644
index 12ff493..0000000
--- a/packages/adapters/src/openai-env-model.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import {
- createOpenAIChatCompletionsModel,
- type CanonicalModelCompletion,
- type OpenAIChatCompletionsModelConfig,
-} from "@memflywheel/model";
-
-export type EnvLike = Readonly>;
-
-export interface OpenAICompatibleEnvModelOptions {
- readonly env?: EnvLike;
- readonly endpoint?: string;
- readonly apiKey?: string;
- readonly model?: string;
- readonly maxTokens?: number;
- readonly temperature?: number;
- readonly fetchImpl?: typeof fetch;
-}
-
-export interface ResolvedOpenAICompatibleEnvModelConfig {
- readonly endpoint?: string;
- readonly apiKey?: string;
- readonly model?: string;
- readonly maxTokens?: number;
- readonly temperature?: number;
- readonly fetchImpl?: typeof fetch;
-}
-
-const ENDPOINT_KEYS = [
- "MEMFLYWHEEL_LLM_ENDPOINT",
- "MEMFLYWHEEL_LLM_BASE_URL",
- "OPENAI_BASE_URL",
- "OPENAI_API_BASE",
- "DEEPSEEK_BASE_URL",
- "CUSTOM_BASE_URL",
-] as const;
-
-const API_KEY_KEYS = [
- "MEMFLYWHEEL_LLM_API_KEY",
- "OPENAI_API_KEY",
- "DEEPSEEK_API_KEY",
- "CUSTOM_API_KEY",
-] as const;
-
-const MODEL_KEYS = [
- "MEMFLYWHEEL_LLM_MODEL",
- "OPENAI_MODEL",
- "DEEPSEEK_MODEL",
- "CUSTOM_MODEL",
-] as const;
-
-function envValue(env: EnvLike, keys: readonly string[]): string | undefined {
- for (const key of keys) {
- const value = env[key]?.trim();
- if (value) return value;
- }
- return undefined;
-}
-
-function normalizeEndpoint(endpoint: string | undefined): string | undefined {
- if (!endpoint) return undefined;
- return endpoint.replace(/\/chat\/completions\/?$/, "").replace(/\/+$/, "");
-}
-
-export function resolveOpenAICompatibleEnvModelConfig(
- options: OpenAICompatibleEnvModelOptions = {},
-): ResolvedOpenAICompatibleEnvModelConfig {
- const env = options.env ?? process.env;
- return {
- endpoint: normalizeEndpoint(options.endpoint ?? envValue(env, ENDPOINT_KEYS)),
- apiKey: options.apiKey ?? envValue(env, API_KEY_KEYS),
- model: options.model ?? envValue(env, MODEL_KEYS),
- maxTokens: options.maxTokens,
- temperature: options.temperature,
- fetchImpl: options.fetchImpl,
- };
-}
-
-export function createOpenAICompatibleEnvModel(
- options: OpenAICompatibleEnvModelOptions = {},
-): CanonicalModelCompletion {
- const config = resolveOpenAICompatibleEnvModelConfig(options);
- const modelConfig: OpenAIChatCompletionsModelConfig = {};
- if (config.endpoint) modelConfig.endpoint = config.endpoint;
- if (config.apiKey) modelConfig.apiKey = config.apiKey;
- if (config.model) modelConfig.model = config.model;
- if (config.maxTokens !== undefined) modelConfig.maxTokens = config.maxTokens;
- if (config.temperature !== undefined) modelConfig.temperature = config.temperature;
- if (config.fetchImpl) modelConfig.fetchImpl = config.fetchImpl;
- return createOpenAIChatCompletionsModel(modelConfig);
-}
diff --git a/packages/adapters/src/openclaw-native-model.ts b/packages/adapters/src/openclaw-native-model.ts
new file mode 100644
index 0000000..fc9b667
--- /dev/null
+++ b/packages/adapters/src/openclaw-native-model.ts
@@ -0,0 +1,87 @@
+import {
+ createAssistantMessageEventStream,
+ type Api,
+ type AssistantMessage,
+ type Model,
+} from "@earendil-works/pi-ai";
+import type { ResolvePiAgentModel } from "@memflywheel/sdk";
+
+type RawRecord = Record;
+
+export interface OpenClawNativeModelSelection {
+ readonly agentId?: string;
+ readonly modelRef?: string;
+}
+
+export interface OpenClawNativeModelRuntime {
+ readonly currentConfig: () => unknown;
+ readonly resolveDefaultAgentId: (config: unknown) => string;
+ readonly prepareForAgent: (input: {
+ readonly cfg: unknown;
+ readonly agentId: string;
+ readonly modelRef?: string;
+ }) => Promise;
+ readonly completePrepared: (input: {
+ readonly model: unknown;
+ readonly auth: unknown;
+ readonly context: unknown;
+ readonly cfg: unknown;
+ readonly options: { readonly signal?: AbortSignal };
+ }) => Promise;
+}
+
+function isRecord(value: unknown): value is RawRecord {
+ return Boolean(value) && typeof value === "object";
+}
+
+function assistantMessage(value: unknown): AssistantMessage {
+ if (!isRecord(value) || value.role !== "assistant" || !Array.isArray(value.content)) {
+ throw new Error("OpenClaw native completion returned an invalid Pi assistant message.");
+ }
+ return value as unknown as AssistantMessage;
+}
+
+/** Bind OpenClaw's native model/auth transport to the single Pi Agent Core runner. */
+export function createOpenClawHostModel(
+ runtime: OpenClawNativeModelRuntime,
+ selection: () => OpenClawNativeModelSelection,
+): ResolvePiAgentModel {
+ return async () => {
+ const cfg = runtime.currentConfig();
+ const selected = selection();
+ const agentId = selected.agentId ?? runtime.resolveDefaultAgentId(cfg);
+ if (!agentId) throw new Error("OpenClaw could not resolve the active agent id.");
+ const prepared = await runtime.prepareForAgent({
+ cfg,
+ agentId,
+ ...(selected.modelRef ? { modelRef: selected.modelRef } : {}),
+ });
+ if (!isRecord(prepared)) throw new Error("OpenClaw returned an invalid prepared model.");
+ if (typeof prepared.error === "string") throw new Error(prepared.error);
+ if (!("model" in prepared) || !("auth" in prepared)) {
+ throw new Error("OpenClaw did not provide a prepared model and credential.");
+ }
+
+ return {
+ model: prepared.model as Model,
+ streamFn: async (_model, context, options) => {
+ const message = assistantMessage(
+ await runtime.completePrepared({
+ model: prepared.model,
+ auth: prepared.auth,
+ context,
+ cfg,
+ options: { signal: options?.signal },
+ }),
+ );
+ const stream = createAssistantMessageEventStream();
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
+ stream.push({ type: "error", reason: message.stopReason, error: message });
+ } else {
+ stream.push({ type: "done", reason: message.stopReason, message });
+ }
+ return stream;
+ },
+ };
+ };
+}
diff --git a/packages/adapters/src/openclaw-port.test.ts b/packages/adapters/src/openclaw-port.test.ts
index 39175f3..9f2012a 100644
--- a/packages/adapters/src/openclaw-port.test.ts
+++ b/packages/adapters/src/openclaw-port.test.ts
@@ -2,14 +2,20 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import {
- canonicalMessagesFromOpenClawMessages,
+ hostMessagesFromOpenClawMessages,
createOpenClawHarnessPort,
registerOpenClawMemoryCapability,
+ registerOpenClawSingleWriterGuard,
+ openClawHostMemoryPaths,
type OpenClawApiLike,
} from "./openclaw-port.js";
-import type { CanonicalModelCompletion } from "@memflywheel/model";
+import {
+ createOpenClawHostModel,
+ type OpenClawNativeModelRuntime,
+} from "./openclaw-native-model.js";
+import { resolveTestModel, type TestModelCompletion } from "./model-test-support.test.js";
-const fakeModel: CanonicalModelCompletion = {
+const fakeModel: TestModelCompletion = {
async complete() {
return { message: { role: "assistant", content: "done" } };
},
@@ -53,8 +59,8 @@ function createLegacyFakeApi() {
return { api, hooks };
}
-test("canonicalMessagesFromOpenClawMessages maps OpenAI-style tool calls", () => {
- const messages = canonicalMessagesFromOpenClawMessages([
+test("hostMessagesFromOpenClawMessages maps OpenAI-style tool calls", () => {
+ const messages = hostMessagesFromOpenClawMessages([
{ role: "user", content: "inspect repo" },
{
role: "assistant",
@@ -80,8 +86,8 @@ test("canonicalMessagesFromOpenClawMessages maps OpenAI-style tool calls", () =>
]);
});
-test("canonicalMessagesFromOpenClawMessages maps OpenClaw native tool calls", () => {
- const messages = canonicalMessagesFromOpenClawMessages([
+test("hostMessagesFromOpenClawMessages maps OpenClaw native tool calls", () => {
+ const messages = hostMessagesFromOpenClawMessages([
{ role: "user", content: [{ type: "text", text: "inspect repo" }] },
{
role: "assistant",
@@ -111,12 +117,12 @@ test("canonicalMessagesFromOpenClawMessages maps OpenClaw native tool calls", ()
test("createOpenClawHarnessPort registers prompt and turn hooks", async () => {
const { api, typedHooks, legacyHooks } = createFakeApi();
- const port = createOpenClawHarnessPort(api, { model: fakeModel });
+ const port = createOpenClawHarnessPort(api, { resolveModel: resolveTestModel(fakeModel) });
const seenTurns: unknown[] = [];
port.lifecycle.onPromptBuild(async (event) => ({
systemPrompt: `system:${event.sessionId}:${event.query}`,
- preludePrompt: "memory",
+ preludePrompt: "memory\n\nskill",
skillPreludePrompt: "skill",
}));
port.lifecycle.onTurnEnd(async (event) => {
@@ -158,9 +164,9 @@ test("createOpenClawHarnessPort registers prompt and turn hooks", async () => {
]);
});
-test("createOpenClawHarnessPort runs agent_end work after the hook returns", async () => {
+test("createOpenClawHarnessPort keeps gateway agent_end work in its ordered background queue", async () => {
const { api, typedHooks } = createFakeApi();
- const port = createOpenClawHarnessPort(api, { model: fakeModel });
+ const port = createOpenClawHarnessPort(api, { resolveModel: resolveTestModel(fakeModel) });
const releases: (() => void)[] = [];
let started = 0;
let finished = 0;
@@ -201,11 +207,31 @@ test("createOpenClawHarnessPort runs agent_end work after the hook returns", asy
await new Promise((resolve) => setImmediate(resolve));
assert.equal(finished, 1);
+ assert.equal(hookReturned, true);
+});
+
+test("createOpenClawHarnessPort reports background lifecycle failures through the host logger", async () => {
+ const { api, typedHooks } = createFakeApi();
+ const errors: string[] = [];
+ Object.assign(api, { logger: { error: (message: string) => errors.push(message) } });
+ const port = createOpenClawHarnessPort(api, { resolveModel: resolveTestModel(fakeModel) });
+ port.lifecycle.onTurnEnd(async () => {
+ throw new Error("extraction failed");
+ });
+
+ const turnHook = typedHooks.get("agent_end");
+ assert.ok(turnHook);
+ await turnHook({ messages: [] }, { sessionKey: "agent:main:failure" });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.deepEqual(errors, [
+ "MemFlywheel OpenClaw background lifecycle failed: Error: extraction failed",
+ ]);
});
test("createOpenClawHarnessPort falls back to legacy hook registration", () => {
const { api, hooks } = createLegacyFakeApi();
- const port = createOpenClawHarnessPort(api, { model: fakeModel });
+ const port = createOpenClawHarnessPort(api, { resolveModel: resolveTestModel(fakeModel) });
port.lifecycle.onSessionEnd(async () => undefined);
@@ -222,3 +248,135 @@ test("registerOpenClawMemoryCapability marks MemFlywheel as a memory capability"
"MemFlywheel long-term memory is active.",
]);
});
+
+test("OpenClaw single-writer guard blocks host writes inside memory and permits reads", async () => {
+ const { api, typedHooks } = createFakeApi();
+ registerOpenClawSingleWriterGuard(api, "/home/user/.openclaw/memflywheel", [
+ "/home/user/.openclaw/workspace/MEMORY.md",
+ "/home/user/.openclaw/workspace/memory",
+ ]);
+ const guard = typedHooks.get("before_tool_call");
+ assert.ok(guard);
+
+ assert.deepEqual(
+ await guard({
+ toolName: "write",
+ params: { path: "/home/user/.openclaw/memflywheel/context/probe.md" },
+ }),
+ {
+ block: true,
+ blockReason:
+ "MemFlywheel is single-writer: the host agent may read memory files, but only MemFlywheel memory agents may modify the memory repository.",
+ },
+ );
+ assert.equal(
+ await guard({
+ toolName: "read",
+ params: { path: "/home/user/.openclaw/memflywheel/context/probe.md" },
+ }),
+ undefined,
+ );
+ assert.equal(
+ await guard({ toolName: "write", params: { path: "/home/user/project/output.md" } }),
+ undefined,
+ );
+ assert.equal(
+ (
+ (await guard({
+ toolName: "edit",
+ params: { path: "/home/user/.openclaw/workspace/MEMORY.md" },
+ })) as { block?: boolean }
+ ).block,
+ true,
+ );
+});
+
+test("openClawHostMemoryPaths protects default and per-agent native memory stores", () => {
+ assert.deepEqual(
+ openClawHostMemoryPaths({
+ agents: {
+ defaults: { workspace: "/home/user/default" },
+ list: [{ id: "ops", workspace: "/home/user/ops" }],
+ },
+ }),
+ [
+ "/home/user/default/MEMORY.md",
+ "/home/user/default/memory",
+ "/home/user/ops/MEMORY.md",
+ "/home/user/ops/memory",
+ ],
+ );
+});
+
+test("createOpenClawHostModel uses one host-native structured completion", async () => {
+ const calls: Array<{ kind: string; input: unknown }> = [];
+ const runtime: OpenClawNativeModelRuntime = {
+ currentConfig() {
+ return { agents: { defaults: { model: "deepseek/deepseek-chat" } } };
+ },
+ resolveDefaultAgentId() {
+ return "main";
+ },
+ async prepareForAgent(input) {
+ calls.push({ kind: "prepare", input });
+ return {
+ model: { api: "openai-completions", provider: "deepseek", id: "deepseek-chat" },
+ auth: { mode: "api-key", apiKey: "host-owned" },
+ };
+ },
+ async completePrepared(input) {
+ calls.push({ kind: "complete", input });
+ return {
+ role: "assistant",
+ content: [
+ {
+ type: "toolCall",
+ id: "call-2",
+ name: "write",
+ arguments: { path: "MEMORY.md", content: "tea" },
+ },
+ ],
+ stopReason: "toolUse",
+ };
+ },
+ };
+ const resolveModel = createOpenClawHostModel(runtime, () => ({
+ agentId: "work",
+ modelRef: "deepseek/deepseek-chat",
+ }));
+
+ const binding = await resolveModel();
+ const response = await (
+ await binding.streamFn(
+ binding.model,
+ {
+ systemPrompt: "Extract durable memory.",
+ messages: [{ role: "user", content: "Remember tea.", timestamp: Date.now() }],
+ tools: [],
+ },
+ {},
+ )
+ ).result();
+
+ assert.equal(calls.length, 2);
+ assert.deepEqual(calls[0], {
+ kind: "prepare",
+ input: {
+ cfg: { agents: { defaults: { model: "deepseek/deepseek-chat" } } },
+ agentId: "work",
+ modelRef: "deepseek/deepseek-chat",
+ },
+ });
+ const context = (calls[1]?.input as { context: Record }).context;
+ assert.equal(context.systemPrompt, "Extract durable memory.");
+ assert.equal((context.messages as unknown[]).length, 1);
+ assert.deepEqual(context.tools, []);
+ assert.ok(response.content.some((part) => part.type === "toolCall" && part.name === "write"));
+});
+
+test("createOpenClawHarnessPort fails without the host native model runtime", () => {
+ assert.throws(
+ () => createOpenClawHarnessPort(createFakeApi().api),
+ /requires OpenClaw's native structured model runtime/,
+ );
+});
diff --git a/packages/adapters/src/openclaw-port.ts b/packages/adapters/src/openclaw-port.ts
index 82c1bca..b174938 100644
--- a/packages/adapters/src/openclaw-port.ts
+++ b/packages/adapters/src/openclaw-port.ts
@@ -1,13 +1,19 @@
-import { join } from "node:path";
+import { isAbsolute, join, relative, resolve } from "node:path";
-import type { CanonicalModelCompletion, CanonicalModelMessage } from "@memflywheel/model";
+import type { ResolvePiAgentModel } from "@memflywheel/sdk";
import { createMemFlywheelHarnessRuntime } from "./host-memflywheel.js";
-import { createCapabilitySet, type Dispose, type HostHarnessPort } from "./harness-port.js";
import {
- createOpenAICompatibleEnvModel,
- type OpenAICompatibleEnvModelOptions,
-} from "./openai-env-model.js";
+ createCapabilitySet,
+ type Dispose,
+ type HostHarnessPort,
+ type HostMessage,
+} from "./harness-port.js";
+import {
+ createOpenClawHostModel,
+ type OpenClawNativeModelRuntime,
+ type OpenClawNativeModelSelection,
+} from "./openclaw-native-model.js";
type RawRecord = Record;
type OpenClawHookHandler = (event: unknown, context?: unknown) => Promise | unknown;
@@ -20,12 +26,91 @@ export interface OpenClawApiLike {
opts?: unknown,
) => void;
readonly registerMemoryCapability?: (capability: unknown) => void;
+ readonly logger?: {
+ error(message: string): void;
+ };
+}
+
+const OPENCLAW_FILE_MUTATION_TOOLS = new Set([
+ "write",
+ "edit",
+ "apply_patch",
+ "write_file",
+ "edit_file",
+]);
+
+function isPathInside(root: string, candidate: string): boolean {
+ const rel = relative(resolve(root), resolve(candidate));
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
+}
+
+function mutationTargetPaths(event: unknown): string[] {
+ if (!isRecord(event)) return [];
+ const paths = new Set();
+ if (Array.isArray(event.derivedPaths)) {
+ for (const path of event.derivedPaths) {
+ if (typeof path === "string" && path.trim()) paths.add(path);
+ }
+ }
+ if (isRecord(event.params)) {
+ for (const key of ["path", "file_path", "filePath"]) {
+ const path = event.params[key];
+ if (typeof path === "string" && path.trim()) paths.add(path);
+ }
+ }
+ return [...paths];
+}
+
+export function openClawHostMemoryPaths(config: unknown): string[] {
+ if (!isRecord(config) || !isRecord(config.agents)) return [];
+ const workspaces = new Set();
+ if (isRecord(config.agents.defaults)) {
+ const workspace = readString(config.agents.defaults, "workspace");
+ if (workspace) workspaces.add(workspace);
+ }
+ if (Array.isArray(config.agents.list)) {
+ for (const agent of config.agents.list) {
+ const workspace = readString(agent, "workspace");
+ if (workspace) workspaces.add(workspace);
+ }
+ }
+ return [...workspaces].flatMap((workspace) => [
+ join(workspace, "MEMORY.md"),
+ join(workspace, "memory"),
+ ]);
+}
+
+export function registerOpenClawSingleWriterGuard(
+ api: OpenClawApiLike,
+ root: string,
+ protectedMemoryPaths: readonly string[] = [],
+): void {
+ const protectedPaths = [root, ...protectedMemoryPaths];
+ registerOpenClawHook(
+ api,
+ "before_tool_call",
+ (event) => {
+ const toolName = readString(event, "toolName");
+ if (!toolName || !OPENCLAW_FILE_MUTATION_TOOLS.has(toolName)) return undefined;
+ const targets = mutationTargetPaths(event);
+ if (!targets.some((target) => protectedPaths.some((path) => isPathInside(path, target)))) {
+ return undefined;
+ }
+ return {
+ block: true,
+ blockReason:
+ "MemFlywheel is single-writer: the host agent may read memory files, but only MemFlywheel memory agents may modify the memory repository.",
+ };
+ },
+ "memflywheel-single-writer",
+ );
}
export interface OpenClawHarnessPortOptions {
readonly root?: string;
- readonly model?: CanonicalModelCompletion;
- readonly modelEnv?: OpenAICompatibleEnvModelOptions;
+ readonly protectedMemoryPaths?: readonly string[];
+ readonly resolveModel?: ResolvePiAgentModel;
+ readonly nativeModelRuntime?: OpenClawNativeModelRuntime;
}
function isRecord(value: unknown): value is RawRecord {
@@ -62,8 +147,8 @@ function parseToolInput(argumentsJson: unknown, callId: string): unknown {
}
}
-function canonicalToolCalls(message: RawRecord): NonNullable {
- const calls: NonNullable = [];
+function hostToolCalls(message: RawRecord): NonNullable {
+ const calls: NonNullable = [];
if (Array.isArray(message.toolCalls)) {
for (const raw of message.toolCalls) {
if (!isRecord(raw)) continue;
@@ -94,9 +179,9 @@ function canonicalToolCalls(message: RawRecord): NonNullable 0 ? { role, content: content || null, toolCalls } : { role, content },
@@ -134,15 +219,27 @@ function openClawSessionId(event: unknown, context: unknown): string {
);
}
-function messagesFromAgentEnd(event: unknown): CanonicalModelMessage[] {
+function messagesFromAgentEnd(event: unknown): HostMessage[] {
if (!isRecord(event)) return [];
- return canonicalMessagesFromOpenClawMessages(event.messages ?? event.history);
+ return hostMessagesFromOpenClawMessages(event.messages ?? event.history);
}
-function joinSections(sections: readonly (string | undefined)[]): string | undefined {
- const text = sections
- .filter((section): section is string => Boolean(section?.trim()))
- .join("\n\n");
+function nativeModelSelection(event: unknown, context: unknown): OpenClawNativeModelSelection {
+ const agentId = readString(context, "agentId") ?? readString(event, "agentId");
+ const provider =
+ readString(context, "modelProviderId") ??
+ readString(event, "modelProviderId") ??
+ readString(event, "provider");
+ const modelId =
+ readString(context, "modelId") ?? readString(event, "modelId") ?? readString(event, "model");
+ return {
+ ...(agentId ? { agentId } : {}),
+ ...(provider && modelId ? { modelRef: `${provider}/${modelId}` } : {}),
+ };
+}
+
+function trimmedPrompt(value: string | undefined): string | undefined {
+ const text = value?.trim();
return text || undefined;
}
@@ -175,7 +272,17 @@ export function createOpenClawHarnessPort(
api: OpenClawApiLike,
options: OpenClawHarnessPortOptions = {},
): HostHarnessPort {
- const model = options.model ?? createOpenAICompatibleEnvModel(options.modelEnv);
+ let selection: OpenClawNativeModelSelection = {};
+ const resolveModel =
+ options.resolveModel ??
+ (options.nativeModelRuntime
+ ? createOpenClawHostModel(options.nativeModelRuntime, () => selection)
+ : undefined);
+ if (!resolveModel) {
+ throw new Error(
+ "MemFlywheel OpenClaw integration requires OpenClaw's native structured model runtime.",
+ );
+ }
let backgroundQueue: Promise = Promise.resolve();
function enqueueBackground(task: () => Promise): void {
@@ -185,9 +292,7 @@ export function createOpenClawHarnessPort(
() => undefined,
);
void run.catch((error: unknown) => {
- queueMicrotask(() => {
- throw error;
- });
+ api.logger?.error(`MemFlywheel OpenClaw background lifecycle failed: ${String(error)}`);
});
}
@@ -197,24 +302,24 @@ export function createOpenClawHarnessPort(
"prompt-build",
"turn-end",
"session-end",
- "single-tool-completion",
"agentic-tool-loop",
"tool-trajectory",
]),
- model,
+ resolveModel,
lifecycle: {
onPromptBuild(handler) {
registerOpenClawHook(
api,
"before_prompt_build",
async (event, context) => {
+ selection = nativeModelSelection(event, context);
const result = await handler({
sessionId: openClawSessionId(event, context),
query: readString(event, "prompt"),
});
return {
- prependSystemContext: joinSections([result.systemPrompt]),
- prependContext: joinSections([result.preludePrompt, result.skillPreludePrompt]),
+ prependSystemContext: trimmedPrompt(result.systemPrompt),
+ prependContext: trimmedPrompt(result.preludePrompt),
};
},
"memflywheel-before-prompt-build",
@@ -226,6 +331,7 @@ export function createOpenClawHarnessPort(
api,
"agent_end",
(event, context) => {
+ selection = nativeModelSelection(event, context);
enqueueBackground(() =>
handler({
sessionId: openClawSessionId(event, context),
@@ -264,6 +370,7 @@ export function createOpenClawPluginRuntime(
): Dispose {
registerOpenClawMemoryCapability(api);
const root = options.root ?? defaultOpenClawMemFlywheelRoot();
+ registerOpenClawSingleWriterGuard(api, root, options.protectedMemoryPaths);
const port = createOpenClawHarnessPort(api, options);
const runtime = createMemFlywheelHarnessRuntime({
port,
diff --git a/packages/adapters/src/opencode-pi-model.ts b/packages/adapters/src/opencode-pi-model.ts
new file mode 100644
index 0000000..ac533be
--- /dev/null
+++ b/packages/adapters/src/opencode-pi-model.ts
@@ -0,0 +1,90 @@
+import type {
+ Api,
+ Model,
+ OpenAICompletionsCompat,
+ ProviderStreams,
+ ThinkingLevelMap,
+} from "@earendil-works/pi-ai";
+import type { PiAgentModelBinding } from "@memflywheel/sdk";
+
+type RawRecord = Record;
+
+export interface OpenCodePiModelConfig {
+ readonly api: Api;
+ readonly provider: string;
+ readonly model: string;
+ readonly name: string;
+ readonly baseUrl: string;
+ readonly apiKey?: string;
+ readonly headers?: Record;
+ readonly env?: Record;
+ readonly reasoning: boolean;
+ readonly input: ("text" | "image")[];
+ readonly contextWindow: number;
+ readonly maxTokens: number;
+ readonly compat?: OpenAICompletionsCompat;
+ readonly thinkingLevelMap?: ThinkingLevelMap;
+ readonly temperature?: number;
+ readonly requestOptions?: RawRecord;
+}
+
+const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
+
+async function loadApi(api: Api): Promise {
+ switch (api) {
+ case "openai-completions":
+ return import("@earendil-works/pi-ai/api/openai-completions");
+ case "openai-responses":
+ return import("@earendil-works/pi-ai/api/openai-responses");
+ case "openai-codex-responses":
+ return import("@earendil-works/pi-ai/api/openai-codex-responses");
+ case "azure-openai-responses":
+ return import("@earendil-works/pi-ai/api/azure-openai-responses");
+ case "anthropic-messages":
+ return import("@earendil-works/pi-ai/api/anthropic-messages");
+ case "google-generative-ai":
+ return import("@earendil-works/pi-ai/api/google-generative-ai");
+ case "google-vertex":
+ return import("@earendil-works/pi-ai/api/google-vertex");
+ case "bedrock-converse-stream":
+ return import("@earendil-works/pi-ai/api/bedrock-converse-stream");
+ case "mistral-conversations":
+ return import("@earendil-works/pi-ai/api/mistral-conversations");
+ default:
+ throw new Error(`MemFlywheel has no pi-ai loader for API ${api}.`);
+ }
+}
+
+export function createPiAiModelBinding(config: OpenCodePiModelConfig): PiAgentModelBinding {
+ const model: Model = {
+ id: config.model,
+ name: config.name,
+ api: config.api,
+ provider: config.provider,
+ baseUrl: config.baseUrl,
+ reasoning: config.reasoning,
+ input: config.input,
+ cost: zeroCost,
+ contextWindow: config.contextWindow,
+ maxTokens: config.maxTokens,
+ ...(config.headers ? { headers: config.headers } : {}),
+ ...(config.compat ? { compat: config.compat } : {}),
+ ...(config.thinkingLevelMap ? { thinkingLevelMap: config.thinkingLevelMap } : {}),
+ };
+
+ return {
+ model,
+ ...(config.apiKey ? { getApiKey: () => config.apiKey } : {}),
+ request: {
+ ...(config.requestOptions ?? {}),
+ ...(config.headers ? { headers: config.headers } : {}),
+ ...(config.env ? { env: config.env } : {}),
+ ...(config.temperature === undefined ? {} : { temperature: config.temperature }),
+ maxTokens: config.maxTokens,
+ },
+ streamFn: async (activeModel, context, options) => {
+ const api = await loadApi(activeModel.api);
+ return api.streamSimple(activeModel, context, options);
+ },
+ };
+}
diff --git a/packages/adapters/src/opencode-port.test.ts b/packages/adapters/src/opencode-port.test.ts
index 4c23a8b..9bbfe4c 100644
--- a/packages/adapters/src/opencode-port.test.ts
+++ b/packages/adapters/src/opencode-port.test.ts
@@ -2,162 +2,213 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import {
- canonicalMessagesFromOpenCodeSessionMessages,
+ hostMessagesFromOpenCodeSessionMessages,
+ configureOpenCodeMemoryPermission,
createOpenCodeHarnessPort,
- type OpenCodeClientLike,
+ piApiForOpenCodeTransport,
} from "./opencode-port.js";
-import type { CanonicalModelCompletion } from "@memflywheel/model";
+import { resolveTestModel, type TestModelCompletion } from "./model-test-support.test.js";
-const fakeModel: CanonicalModelCompletion = {
+const fakeModel: TestModelCompletion = {
async complete() {
return { message: { role: "assistant", content: "done" } };
},
};
-function openCodeTranscript() {
+function openCodeModel(npm: string, id: string, url: string) {
return {
- data: [
- {
- info: { role: "user" },
- parts: [{ type: "text", text: "please inspect files" }],
- },
+ id,
+ providerID: "test-provider",
+ name: id,
+ api: { id, npm, url },
+ capabilities: { reasoning: false, input: { text: true, image: false } },
+ limit: { context: 128_000, output: 8_192 },
+ headers: {},
+ };
+}
+
+test("configureOpenCodeMemoryPermission grants every session the memory root", () => {
+ const config: { permission?: unknown } = {
+ permission: { read: { "*.env": "deny" }, external_directory: { "*": "ask" } },
+ };
+ configureOpenCodeMemoryPermission(config, "/Users/test/.config/opencode/memflywheel");
+ assert.deepEqual(config.permission, {
+ read: { "*.env": "deny" },
+ external_directory: {
+ "*": "ask",
+ "/Users/test/.config/opencode/memflywheel/*": "allow",
+ },
+ });
+});
+
+test("OpenCode transcript conversion preserves tool inputs and outputs", () => {
+ assert.deepEqual(
+ hostMessagesFromOpenCodeSessionMessages([
+ { info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] },
{
info: { role: "assistant" },
parts: [
- { type: "text", text: "I will inspect them." },
+ { type: "text", text: "working" },
{
type: "tool",
- callID: "tool-1",
tool: "read",
- state: { status: "completed", input: { filePath: "README.md" }, output: "contents" },
+ callID: "c1",
+ state: { status: "completed", input: { path: "MEMORY.md" }, output: "empty" },
},
],
},
- ],
- };
-}
-
-function openCodeUserOnlyTranscript() {
- return {
- data: [
+ ]),
+ [
+ { role: "user", content: "remember tea" },
{
- info: { role: "user" },
- parts: [{ type: "text", text: "remember apples" }],
+ role: "assistant",
+ content: "working",
+ toolCalls: [{ id: "c1", name: "read", input: { path: "MEMORY.md" } }],
},
+ { role: "tool", toolCallId: "c1", content: "empty" },
],
- };
-}
-
-test("canonicalMessagesFromOpenCodeSessionMessages folds tool parts into assistant messages", () => {
- assert.deepEqual(canonicalMessagesFromOpenCodeSessionMessages(openCodeTranscript()), [
- { role: "user", content: "please inspect files" },
- {
- role: "assistant",
- content: "I will inspect them.",
- toolCalls: [{ id: "tool-1", name: "read", input: { filePath: "README.md" } }],
- },
- { role: "tool", toolCallId: "tool-1", content: "contents" },
- ]);
+ );
});
-test("createOpenCodeHarnessPort injects prompt context and forwards idle transcript", async () => {
- let readOptions: unknown;
- const client: OpenCodeClientLike = {
+test("OpenCode port injects recall and forwards the real idle transcript", async () => {
+ const client = {
session: {
- async messages(options) {
- readOptions = options;
- return openCodeTranscript();
- },
+ messages: async () => ({
+ data: [{ info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] }],
+ }),
},
};
- const port = createOpenCodeHarnessPort(client, { model: fakeModel });
- const seenTurns: unknown[] = [];
- const seenPromptEvents: unknown[] = [];
-
- port.lifecycle.onPromptBuild(async (event) => ({
- systemPrompt: `system:${event.sessionId}:${event.query}`,
- preludePrompt: "memory",
- skillPreludePrompt: "skill",
- }));
- port.lifecycle.onPromptBuild(async (event) => {
- seenPromptEvents.push(event);
- return {};
- });
- port.lifecycle.onTurnEnd(async (event) => {
- seenTurns.push(event);
+ const port = createOpenCodeHarnessPort(client, {
+ resolveModel: resolveTestModel(fakeModel),
});
+ port.lifecycle.onPromptBuild(async () => ({
+ systemPrompt: "rules",
+ preludePrompt: "index",
+ skillPreludePrompt: "skills",
+ }));
+ const turns: unknown[] = [];
+ port.lifecycle.onTurnEnd(async (turn) => void turns.push(turn));
const output = { system: [] as string[] };
- await port.hooks["chat.message"]({ sessionID: "oc-1" }, {});
- await port.hooks["experimental.chat.system.transform"]({}, output);
- await port.hooks["experimental.text.complete"](
- { sessionID: "oc-1", messageID: "m1", partID: "p1" },
- { text: "done" },
- );
+ await port.hooks["experimental.chat.system.transform"]({ sessionID: "oc-1" }, output);
+ await port.hooks.event({ event: { type: "session.idle", properties: { sessionID: "oc-1" } } });
- assert.deepEqual(output.system, ["system:oc-1:please inspect files", "memory", "skill"]);
- assert.deepEqual(seenPromptEvents, [{ sessionId: "oc-1", query: "please inspect files" }]);
- assert.deepEqual(readOptions, { path: { id: "oc-1" }, query: { limit: 200 } });
- assert.deepEqual(seenTurns, [
- {
- sessionId: "oc-1",
- messages: canonicalMessagesFromOpenCodeSessionMessages(openCodeTranscript()),
- },
+ assert.deepEqual(output.system, ["rules", "index", "skills"]);
+ assert.deepEqual(turns, [
+ { sessionId: "oc-1", messages: [{ role: "user", content: "remember tea" }] },
]);
});
-test("createOpenCodeHarnessPort deduplicates repeated text completion hooks", async () => {
- const client: OpenCodeClientLike = {
- session: {
- async messages() {
- return openCodeTranscript();
- },
- },
+test("OpenCode serializes text-complete and idle through one deduplicated turn submitter", async () => {
+ let transcript = {
+ data: [{ info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] }],
};
- const port = createOpenCodeHarnessPort(client, { model: fakeModel });
- let turns = 0;
- port.lifecycle.onTurnEnd(async () => {
- turns += 1;
- });
-
- await port.hooks["experimental.text.complete"](
- { sessionID: "oc-2", messageID: "m1", partID: "p1" },
- { text: "done" },
+ const port = createOpenCodeHarnessPort(
+ { session: { messages: async () => transcript } },
+ { resolveModel: resolveTestModel(fakeModel) },
);
- await port.hooks["experimental.text.complete"](
- { sessionID: "oc-2", messageID: "m1", partID: "p1" },
- { text: "done" },
- );
-
- assert.equal(turns, 1);
-});
-
-test("createOpenCodeHarnessPort includes completed assistant text before session transcript catches up", async () => {
- const client: OpenCodeClientLike = {
- session: {
- async messages() {
- return openCodeUserOnlyTranscript();
- },
- },
- };
- const port = createOpenCodeHarnessPort(client, { model: fakeModel });
- const seenTurns: unknown[] = [];
- port.lifecycle.onTurnEnd(async (event) => {
- seenTurns.push(event);
- });
+ const turns: unknown[] = [];
+ port.lifecycle.onTurnEnd(async (turn) => void turns.push(turn));
await port.hooks["experimental.text.complete"](
- { sessionID: "oc-3", messageID: "m1", partID: "p1" },
+ { sessionID: "oc-idle", messageID: "m1", partID: "p1" },
{ text: "noted" },
);
+ assert.equal(turns.length, 1);
- assert.deepEqual(seenTurns, [
+ const idle = { event: { type: "session.idle", properties: { sessionID: "oc-idle" } } };
+ await Promise.all([port.hooks.event(idle), port.hooks.event(idle)]);
+ assert.deepEqual(turns, [
{
- sessionId: "oc-3",
+ sessionId: "oc-idle",
messages: [
- { role: "user", content: "remember apples" },
+ { role: "user", content: "remember tea" },
{ role: "assistant", content: "noted" },
],
},
]);
+
+ transcript = {
+ data: [
+ { info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "noted" }] },
+ { info: { role: "user" }, parts: [{ type: "text", text: "and coffee" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "also noted" }] },
+ ],
+ };
+ await port.hooks.event(idle);
+ assert.equal(turns.length, 2);
+});
+
+test("OpenCode port fails before chat.params supplies the active model", async () => {
+ const port = createOpenCodeHarnessPort({});
+ await assert.rejects(
+ async () => port.resolveModel(),
+ /has not received OpenCode's active model context/,
+ );
+});
+
+test("OpenCode resolves DeepSeek and Anthropic directly into pi-ai model bindings", async () => {
+ for (const [npm, id, url, expectedApi, expectedBaseUrl] of [
+ [
+ "@ai-sdk/openai-compatible",
+ "deepseek-chat",
+ "https://api.deepseek.com",
+ "openai-completions",
+ "https://api.deepseek.com",
+ ],
+ [
+ "@ai-sdk/anthropic",
+ "x2p",
+ "https://anthropic-gateway.example.com/v1",
+ "anthropic-messages",
+ "https://anthropic-gateway.example.com",
+ ],
+ ] as const) {
+ const port = createOpenCodeHarnessPort({});
+ await port.hooks["chat.params"](
+ {
+ sessionID: "oc-model",
+ model: openCodeModel(npm, id, url),
+ provider: { id: expectedApi, source: "api", key: "host-owned", options: {} },
+ },
+ { maxOutputTokens: 4096, options: {} },
+ );
+ const binding = await port.resolveModel();
+ assert.equal(binding.model.api, expectedApi);
+ assert.equal(binding.model.baseUrl, expectedBaseUrl);
+ assert.equal(binding.model.id, id);
+ assert.equal(await binding.getApiKey?.(binding.model.provider), "host-owned");
+ assert.equal(binding.request?.maxTokens, 4096);
+ }
+});
+
+test("OpenCode transport mapping is exact and rejects unknown transports", () => {
+ assert.deepEqual(
+ [
+ "@ai-sdk/openai-compatible",
+ "@ai-sdk/openai",
+ "@ai-sdk/azure",
+ "@ai-sdk/anthropic",
+ "@ai-sdk/google",
+ "@ai-sdk/google-vertex",
+ "@ai-sdk/amazon-bedrock",
+ "@ai-sdk/mistral",
+ ].map((apiPackage) => piApiForOpenCodeTransport(apiPackage)),
+ [
+ "openai-completions",
+ "openai-responses",
+ "azure-openai-responses",
+ "anthropic-messages",
+ "google-generative-ai",
+ "google-vertex",
+ "bedrock-converse-stream",
+ "mistral-conversations",
+ ],
+ );
+ assert.throws(() => piApiForOpenCodeTransport("@ai-sdk/unknown"), /no exact pi-ai API mapping/);
+ assert.equal(
+ piApiForOpenCodeTransport("@ai-sdk/openai", "openai-codex"),
+ "openai-codex-responses",
+ );
});
diff --git a/packages/adapters/src/opencode-port.ts b/packages/adapters/src/opencode-port.ts
index 48c7eed..562761b 100644
--- a/packages/adapters/src/opencode-port.ts
+++ b/packages/adapters/src/opencode-port.ts
@@ -1,23 +1,17 @@
import { join } from "node:path";
-import type {
- CanonicalModelCompletion,
- CanonicalModelMessage,
- CanonicalToolCall,
-} from "@memflywheel/model";
+import type { PiAgentModelBinding, ResolvePiAgentModel } from "@memflywheel/sdk";
import { createMemFlywheelHarnessRuntime } from "./host-memflywheel.js";
import {
createCapabilitySet,
type HostHarnessPort,
+ type HostMessage,
+ type HostToolCall,
type HostToolCallEvent,
type HostToolResultEvent,
} from "./harness-port.js";
-import {
- createOpenAICompatibleEnvModel,
- type OpenAICompatibleEnvModelOptions,
-} from "./openai-env-model.js";
-
+import { createPiAiModelBinding } from "./opencode-pi-model.js";
type RawRecord = Record;
export interface OpenCodeClientLike {
@@ -32,18 +26,33 @@ export interface OpenCodePluginInput {
export interface OpenCodeHarnessPortOptions {
readonly root?: string;
- readonly model?: CanonicalModelCompletion;
- readonly modelEnv?: OpenAICompatibleEnvModelOptions;
+ readonly resolveModel?: ResolvePiAgentModel;
readonly messageLimit?: number;
}
export interface OpenCodeHooks {
readonly dispose?: () => Promise | void;
+ readonly config: (config: { permission?: unknown }) => Promise;
readonly event: (input: { readonly event: unknown }) => Promise;
readonly "chat.message": (
- input: { readonly sessionID: string },
+ input: {
+ readonly sessionID: string;
+ readonly model?: { readonly providerID?: string; readonly modelID?: string };
+ },
output: unknown,
) => Promise;
+ readonly "chat.params": (
+ input: {
+ readonly sessionID: string;
+ readonly model: unknown;
+ readonly provider: unknown;
+ },
+ output: {
+ readonly temperature?: number;
+ readonly maxOutputTokens?: number;
+ readonly options?: RawRecord;
+ },
+ ) => Promise;
readonly "experimental.chat.system.transform": (
input: { readonly sessionID?: string },
output: { system: string[] },
@@ -77,6 +86,27 @@ export function defaultOpenCodeMemFlywheelRoot(env: NodeJS.ProcessEnv = process.
return join(configRoot, "memflywheel");
}
+const PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
+
+function permissionRuleObject(value: unknown): RawRecord {
+ if (typeof value === "string" && PERMISSION_ACTIONS.has(value)) return { "*": value };
+ return isRecord(value) ? { ...value } : {};
+}
+
+/** Allow every OpenCode session to progressively read the active MemFlywheel store. */
+export function configureOpenCodeMemoryPermission(
+ config: { permission?: unknown },
+ root: string,
+): void {
+ const permission = permissionRuleObject(config.permission);
+ const externalDirectory = permissionRuleObject(permission.external_directory);
+ externalDirectory[join(root, "*")] = "allow";
+ config.permission = {
+ ...permission,
+ external_directory: externalDirectory,
+ };
+}
+
function isRecord(value: unknown): value is RawRecord {
return Boolean(value) && typeof value === "object";
}
@@ -87,6 +117,208 @@ function readString(value: unknown, key: string): string | undefined {
return typeof raw === "string" && raw.trim() ? raw : undefined;
}
+function readNumber(value: unknown, key: string): number | undefined {
+ if (!isRecord(value)) return undefined;
+ const raw = value[key];
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
+}
+
+function stringRecord(value: unknown): Record {
+ if (!isRecord(value)) return {};
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ (entry): entry is [string, string] => typeof entry[1] === "string",
+ ),
+ );
+}
+
+function openCodeProviderInfo(provider: unknown): RawRecord {
+ if (!isRecord(provider)) throw new Error("OpenCode did not provide model provider context.");
+ if (typeof provider.id === "string") return provider;
+ if (isRecord(provider.info) && typeof provider.info.id === "string") return provider.info;
+ throw new Error("OpenCode model provider context has no provider info.");
+}
+
+function openCodeProviderOptions(provider: unknown, output: unknown): RawRecord {
+ const info = openCodeProviderInfo(provider);
+ const hookOptions = isRecord(output) && isRecord(output.options) ? output.options : {};
+ const contextOptions = isRecord(provider) && isRecord(provider.options) ? provider.options : {};
+ const infoOptions = isRecord(info.options) ? info.options : {};
+ return { ...infoOptions, ...contextOptions, ...hookOptions };
+}
+
+export function piApiForOpenCodeTransport(
+ apiPackage: string,
+ providerId?: string,
+):
+ | "openai-completions"
+ | "openai-responses"
+ | "openai-codex-responses"
+ | "azure-openai-responses"
+ | "anthropic-messages"
+ | "google-generative-ai"
+ | "google-vertex"
+ | "bedrock-converse-stream"
+ | "mistral-conversations" {
+ switch (apiPackage) {
+ case "@ai-sdk/openai-compatible":
+ return "openai-completions";
+ case "@ai-sdk/openai":
+ return providerId === "openai-codex" ? "openai-codex-responses" : "openai-responses";
+ case "@ai-sdk/azure":
+ return "azure-openai-responses";
+ case "@ai-sdk/anthropic":
+ return "anthropic-messages";
+ case "@ai-sdk/google":
+ return "google-generative-ai";
+ case "@ai-sdk/google-vertex":
+ return "google-vertex";
+ case "@ai-sdk/amazon-bedrock":
+ return "bedrock-converse-stream";
+ case "@ai-sdk/mistral":
+ return "mistral-conversations";
+ default:
+ throw new Error(
+ `OpenCode transport ${apiPackage} has no exact pi-ai API mapping in MemFlywheel.`,
+ );
+ }
+}
+
+function openCodeProviderEnv(options: RawRecord): Record {
+ const env: Record = {};
+ const mappings = {
+ accessKeyId: "AWS_ACCESS_KEY_ID",
+ secretAccessKey: "AWS_SECRET_ACCESS_KEY",
+ sessionToken: "AWS_SESSION_TOKEN",
+ } as const;
+ for (const [option, name] of Object.entries(mappings)) {
+ const value = readString(options, option);
+ if (value) env[name] = value;
+ }
+ return env;
+}
+
+function openCodePiRequestOptions(
+ api: ReturnType,
+ providerOptions: RawRecord,
+): RawRecord {
+ if (api === "bedrock-converse-stream") {
+ return {
+ ...(readString(providerOptions, "region")
+ ? { region: readString(providerOptions, "region") }
+ : {}),
+ ...(readString(providerOptions, "profile")
+ ? { profile: readString(providerOptions, "profile") }
+ : {}),
+ ...(readString(providerOptions, "bearerToken")
+ ? { bearerToken: readString(providerOptions, "bearerToken") }
+ : {}),
+ };
+ }
+ if (api === "google-vertex") {
+ return {
+ ...(readString(providerOptions, "project")
+ ? { project: readString(providerOptions, "project") }
+ : {}),
+ ...(readString(providerOptions, "location")
+ ? { location: readString(providerOptions, "location") }
+ : {}),
+ };
+ }
+ if (api === "azure-openai-responses") {
+ return {
+ ...(readString(providerOptions, "apiVersion")
+ ? { azureApiVersion: readString(providerOptions, "apiVersion") }
+ : {}),
+ ...(readString(providerOptions, "resourceName")
+ ? { azureResourceName: readString(providerOptions, "resourceName") }
+ : {}),
+ ...(readString(providerOptions, "deploymentName")
+ ? { azureDeploymentName: readString(providerOptions, "deploymentName") }
+ : {}),
+ };
+ }
+ return {};
+}
+
+function openCodePiBaseUrl(
+ api: ReturnType,
+ endpoint: string,
+): string {
+ if (api === "anthropic-messages") return endpoint.replace(/\/v1\/?$/, "");
+ return endpoint;
+}
+
+const OPENAI_COMPATIBLE_TRANSPORT = {
+ supportsStore: false,
+ supportsDeveloperRole: false,
+ supportsReasoningEffort: false,
+ maxTokensField: "max_tokens" as const,
+ supportsStrictMode: false,
+};
+
+/** Build a pi-ai completion from OpenCode's resolved model and credential context. */
+export function createOpenCodeHostModel(
+ input: { readonly model: unknown; readonly provider: unknown },
+ output: {
+ readonly temperature?: number;
+ readonly maxOutputTokens?: number;
+ readonly options?: RawRecord;
+ },
+): PiAgentModelBinding {
+ if (!isRecord(input.model)) throw new Error("OpenCode did not provide the active model.");
+ const modelId = readString(input.model, "id") ?? readString(input.model, "modelID");
+ const api = isRecord(input.model.api) ? input.model.api : undefined;
+ const providerInfo = openCodeProviderInfo(input.provider);
+ const providerOptions = openCodeProviderOptions(input.provider, output);
+ const apiPackage = api ? readString(api, "npm") : undefined;
+
+ if (!modelId) throw new Error("OpenCode active model has no model id.");
+ if (!apiPackage) throw new Error(`OpenCode model ${modelId} has no transport package.`);
+ const providerId = readString(providerInfo, "id") ?? "unknown";
+ const piApi = piApiForOpenCodeTransport(apiPackage, providerId);
+
+ const endpoint =
+ readString(providerOptions, "baseURL") ?? (api ? readString(api, "url") : undefined);
+ const apiKey = readString(providerOptions, "apiKey") ?? readString(providerInfo, "key");
+ if (!endpoint && piApi !== "bedrock-converse-stream" && piApi !== "google-vertex") {
+ throw new Error(`OpenCode model ${modelId} has no resolved endpoint.`);
+ }
+ const capabilities = isRecord(input.model.capabilities) ? input.model.capabilities : {};
+ const inputCapabilities = isRecord(capabilities.input) ? capabilities.input : {};
+ const limit = isRecord(input.model.limit) ? input.model.limit : {};
+ const modelHeaders = stringRecord(input.model.headers);
+ const providerHeaders = stringRecord(providerOptions.headers);
+ const maxTokens = output.maxOutputTokens ?? readNumber(limit, "output");
+ const contextWindow = readNumber(limit, "context");
+ if (!maxTokens || !contextWindow) {
+ throw new Error(`OpenCode model ${modelId} has incomplete token limits.`);
+ }
+
+ return createPiAiModelBinding({
+ api: piApi,
+ provider: providerId,
+ model: api ? (readString(api, "id") ?? modelId) : modelId,
+ name: readString(input.model, "name") ?? modelId,
+ baseUrl: openCodePiBaseUrl(piApi, endpoint ?? ""),
+ apiKey,
+ headers: { ...modelHeaders, ...providerHeaders },
+ env: openCodeProviderEnv(providerOptions),
+ reasoning: capabilities.reasoning === true,
+ input: ["text", ...(inputCapabilities.image === true ? (["image"] as const) : [])],
+ contextWindow,
+ maxTokens,
+ ...(piApi === "openai-completions"
+ ? {
+ compat: OPENAI_COMPATIBLE_TRANSPORT,
+ thinkingLevelMap: { off: null },
+ }
+ : {}),
+ temperature: output.temperature,
+ requestOptions: openCodePiRequestOptions(piApi, providerOptions),
+ });
+}
+
function readTextFromParts(parts: unknown): string {
if (!Array.isArray(parts)) return "";
return parts
@@ -103,15 +335,13 @@ function toolOutputFromState(state: RawRecord): string | undefined {
return undefined;
}
-export function canonicalMessagesFromOpenCodeSessionMessages(
- raw: unknown,
-): CanonicalModelMessage[] {
+export function hostMessagesFromOpenCodeSessionMessages(raw: unknown): HostMessage[] {
const entries = Array.isArray(raw)
? raw
: isRecord(raw) && Array.isArray(raw.data)
? raw.data
: [];
- const messages: CanonicalModelMessage[] = [];
+ const messages: HostMessage[] = [];
for (const entry of entries) {
if (!isRecord(entry) || !isRecord(entry.info)) continue;
const role = entry.info.role;
@@ -123,7 +353,7 @@ export function canonicalMessagesFromOpenCodeSessionMessages(
continue;
}
- const toolCalls: CanonicalToolCall[] = [];
+ const toolCalls: HostToolCall[] = [];
if (Array.isArray(entry.parts)) {
for (const part of entry.parts) {
if (!isRecord(part) || part.type !== "tool" || !isRecord(part.state)) continue;
@@ -163,13 +393,13 @@ async function readOpenCodeMessages(
client: OpenCodeClientLike,
sessionId: string,
messageLimit: number,
-): Promise {
+): Promise {
const response = await client.session?.messages?.({
path: { id: sessionId },
query: { limit: messageLimit },
});
if (!response) throw new Error("OpenCode client.session.messages returned no response");
- return canonicalMessagesFromOpenCodeSessionMessages(response);
+ return hostMessagesFromOpenCodeSessionMessages(response);
}
function appendPromptBuildResult(
@@ -185,7 +415,7 @@ function appendPromptBuildResult(
}
}
-function latestUserQuery(messages: readonly CanonicalModelMessage[]): string | undefined {
+function latestUserQuery(messages: readonly HostMessage[]): string | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role === "user" && message.content?.trim()) return message.content.trim();
@@ -193,10 +423,7 @@ function latestUserQuery(messages: readonly CanonicalModelMessage[]): string | u
return undefined;
}
-function withCompletedAssistantText(
- messages: readonly CanonicalModelMessage[],
- text: string,
-): CanonicalModelMessage[] {
+function withCompletedAssistantText(messages: readonly HostMessage[], text: string): HostMessage[] {
const completedText = text.trim();
if (!completedText) return [...messages];
if (messages.some((message) => message.role === "assistant" && message.content?.trim())) {
@@ -209,32 +436,78 @@ export function createOpenCodeHarnessPort(
client: OpenCodeClientLike,
options: OpenCodeHarnessPortOptions = {},
): HostHarnessPort & { readonly hooks: OpenCodeHooks } {
- const model = options.model ?? createOpenAICompatibleEnvModel(options.modelEnv);
+ let hostModel: PiAgentModelBinding | undefined;
+ const resolveModel: ResolvePiAgentModel =
+ options.resolveModel ??
+ (() => {
+ if (!hostModel) {
+ throw new Error(
+ "MemFlywheel has not received OpenCode's active model context. " +
+ "A host chat.params event must occur before memory extraction.",
+ );
+ }
+ return hostModel;
+ });
const messageLimit = options.messageLimit ?? 200;
let lastSessionId: string | undefined;
- const completedTextParts = new Set();
+ const pendingCompletedText = new Map }>();
+ const deliveredTranscripts = new Map();
+ const turnDispatches = new Map>();
const promptHandlers = new Set[0]>();
const turnHandlers = new Set[0]>();
const sessionEndHandlers = new Set[0]>();
const toolCallHandlers = new Set<(event: HostToolCallEvent) => Promise>();
const toolResultHandlers = new Set<(event: HostToolResultEvent) => Promise>();
+ const dispatchCompletedTurn = async (sessionId: string): Promise => {
+ const previous = turnDispatches.get(sessionId) ?? Promise.resolve();
+ const current = previous.then(async () => {
+ const hostMessages = await readOpenCodeMessages(client, sessionId, messageLimit);
+ const hostHasAssistantText = hostMessages.some(
+ (message) => message.role === "assistant" && message.content?.trim(),
+ );
+ const completedText = [...(pendingCompletedText.get(sessionId)?.parts.values() ?? [])].join(
+ "\n",
+ );
+ const messages = withCompletedAssistantText(hostMessages, completedText);
+ const transcript = JSON.stringify(messages);
+ if (deliveredTranscripts.get(sessionId) === transcript) return;
+ for (const handler of turnHandlers) await handler({ sessionId, messages });
+ deliveredTranscripts.set(sessionId, transcript);
+ if (hostHasAssistantText) pendingCompletedText.delete(sessionId);
+ });
+ turnDispatches.set(sessionId, current);
+ try {
+ await current;
+ } finally {
+ if (turnDispatches.get(sessionId) === current) turnDispatches.delete(sessionId);
+ }
+ };
+
const hooks: OpenCodeHooks = {
+ async config(config) {
+ configureOpenCodeMemoryPermission(config, options.root ?? defaultOpenCodeMemFlywheelRoot());
+ },
async event({ event }) {
const type = readString(event, "type");
const sessionId = extractSessionId(event);
if (sessionId) lastSessionId = sessionId;
if (type === "session.idle" && sessionId) {
- const messages = await readOpenCodeMessages(client, sessionId, messageLimit);
- for (const handler of turnHandlers) await handler({ sessionId, messages });
+ await dispatchCompletedTurn(sessionId);
}
if (type === "session.deleted" && sessionId) {
for (const handler of sessionEndHandlers) await handler({ sessionId });
+ pendingCompletedText.delete(sessionId);
+ deliveredTranscripts.delete(sessionId);
+ turnDispatches.delete(sessionId);
}
},
async "chat.message"(input) {
lastSessionId = input.sessionID;
},
+ async "chat.params"(input, output) {
+ if (!options.resolveModel) hostModel = createOpenCodeHostModel(input, output);
+ },
async "experimental.chat.system.transform"(input, output) {
const sessionId = input.sessionID ?? lastSessionId;
const query = sessionId
@@ -246,15 +519,15 @@ export function createOpenCodeHarnessPort(
}
},
async "experimental.text.complete"(input, output) {
- const key = `${input.sessionID}:${input.messageID}:${input.partID}`;
- if (completedTextParts.has(key)) return;
- completedTextParts.add(key);
lastSessionId = input.sessionID;
- const messages = withCompletedAssistantText(
- await readOpenCodeMessages(client, input.sessionID, messageLimit),
- output.text,
- );
- for (const handler of turnHandlers) await handler({ sessionId: input.sessionID, messages });
+ const existing = pendingCompletedText.get(input.sessionID);
+ const pending =
+ existing?.messageId === input.messageID
+ ? existing
+ : { messageId: input.messageID, parts: new Map() };
+ pending.parts.set(input.partID, output.text);
+ pendingCompletedText.set(input.sessionID, pending);
+ await dispatchCompletedTurn(input.sessionID);
},
async "tool.execute.before"(input, output) {
for (const handler of toolCallHandlers) {
@@ -285,11 +558,10 @@ export function createOpenCodeHarnessPort(
"prompt-build",
"turn-end",
"session-end",
- "single-tool-completion",
"agentic-tool-loop",
"tool-trajectory",
]),
- model,
+ resolveModel,
hooks,
lifecycle: {
onPromptBuild(handler) {
@@ -324,7 +596,7 @@ export function createOpenCodePluginServer(
): OpenCodeHooks {
if (!input.client) throw new Error("MemFlywheel OpenCode plugin requires input.client");
const root = options.root ?? defaultOpenCodeMemFlywheelRoot();
- const port = createOpenCodeHarnessPort(input.client, options);
+ const port = createOpenCodeHarnessPort(input.client, { ...options, root });
const runtime = createMemFlywheelHarnessRuntime({
port,
root,
diff --git a/packages/adapters/src/opencode.ts b/packages/adapters/src/opencode.ts
index aa268bf..0cd0274 100644
--- a/packages/adapters/src/opencode.ts
+++ b/packages/adapters/src/opencode.ts
@@ -38,7 +38,7 @@ export const opencodeAdapter: HostAdapter = makeAdapter({
lifecycle,
defaultConfigRelPath: ".config/opencode/opencode.json",
integrationNote:
- "Native OpenCode plugin hooks inject recall and read transcripts; MemFlywheel extraction/skill loops use the configured OpenAI-compatible model endpoint.",
+ "Native OpenCode plugin hooks inject recall and read transcripts; pi-ai maps the active OpenCode model transport into MemFlywheel's extraction and skill loops.",
translators: {
sessionId: (payload) => readString(payload, "sessionID") || readString(payload, "sessionId"),
promptQuery: (payload) =>
diff --git a/packages/adapters/src/pi-port.test.ts b/packages/adapters/src/pi-port.test.ts
index 8e31fda..29b161c 100644
--- a/packages/adapters/src/pi-port.test.ts
+++ b/packages/adapters/src/pi-port.test.ts
@@ -1,240 +1,131 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-
-import {
- createPiHarnessPort,
- type PiCompleteSimple,
- type PiExtensionHandler,
- type PiModelContext,
-} from "./pi-port.js";
-
-test("createPiHarnessPort maps Pi native tool calls into canonical model responses", async () => {
- const events: string[] = [];
- const model = { id: "deepseek-v4-flash", provider: "deepseek" };
- const completeSimple = async (
- actualModel: unknown,
- context: PiModelContext,
- options?: Record,
- ) => {
- assert.equal(actualModel, model);
- assert.equal(context.tools?.[0]?.name, "write");
- assert.ok(context.tools?.[0]?.parameters);
- assert.equal(context.systemPrompt, "You are a memory agent.");
- assert.deepEqual(context.messages, []);
- assert.equal(options?.signal, undefined);
- return {
- role: "assistant" as const,
- content: [
- { type: "text", text: "Saving that preference." },
- {
- type: "toolCall" as const,
- id: "pi_call_1",
- name: "write",
- arguments: {
- filePath: "preference/tea.md",
- content: "---\ntype: preference\nname: Tea\n---\n\nGreen tea\n",
- },
- },
- ],
- stopReason: "toolUse",
- };
+import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
+
+import { createPiHarnessPort, type PiExtensionHandler, type PiStreamSimple } from "./pi-port.js";
+
+function fakePi() {
+ const handlers = new Map();
+ return {
+ handlers,
+ api: {
+ on(event: string, handler: PiExtensionHandler) {
+ handlers.set(event, handler);
+ return () => handlers.delete(event);
+ },
+ },
};
- const pi = {
- on(event: string, _handler: unknown) {
- events.push(event);
- return () => undefined;
+}
+
+const model = {
+ id: "gpt-5.5",
+ name: "GPT-5.5",
+ api: "openai-codex-responses" as const,
+ provider: "openai-codex",
+ baseUrl: "https://chatgpt.com/backend-api/codex",
+ reasoning: true,
+ input: ["text" as const, "image" as const],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 200_000,
+ maxTokens: 32_000,
+};
+
+function assistant(): AssistantMessage {
+ return {
+ role: "assistant",
+ content: [{ type: "text", text: "done", textSignature: "native-signature" }],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ responseId: "resp_native",
+ usage: {
+ input: 10,
+ output: 5,
+ cacheRead: 2,
+ cacheWrite: 0,
+ totalTokens: 17,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
+ stopReason: "stop",
+ timestamp: Date.now(),
};
+}
+
+test("Pi port resolves the active model, auth, thinking level, and isolated session", async () => {
+ const { api, handlers } = fakePi();
+ let capturedApiKey: string | undefined;
+ const streamSimple: PiStreamSimple = (_model, _context, options) => {
+ capturedApiKey = options?.apiKey;
+ const stream = createAssistantMessageEventStream();
+ stream.push({ type: "done", reason: "stop", message: assistant() });
+ return stream;
+ };
+ const port = createPiHarnessPort(api, { streamSimple });
+ port.lifecycle.onPromptBuild(async () => ({}));
- const port = createPiHarnessPort(pi, { completeSimple, piModel: model });
-
- assert.equal(port.name, "pi");
- assert.ok(port.capabilities.has("agentic-tool-loop"));
- assert.ok(port.capabilities.has("tool-trajectory"));
-
- const response = await port.model.complete({
- messages: [{ role: "system", content: "You are a memory agent." }],
- tools: [
- {
- name: "write",
- description: "Write memory file",
- inputSchema: {
- type: "object",
- properties: {},
- required: [],
- additionalProperties: false,
- },
- },
- ],
- });
-
- assert.equal(response.finishReason, "toolUse");
- assert.equal(response.message.content, "Saving that preference.");
- assert.deepEqual(response.message.toolCalls, [
+ await handlers.get("context")?.(
+ { sessionId: "chat-1", messages: [{ role: "user", content: "remember tea" }] },
{
- id: "pi_call_1",
- name: "write",
- input: {
- filePath: "preference/tea.md",
- content: "---\ntype: preference\nname: Tea\n---\n\nGreen tea\n",
- },
- },
- ]);
-
- const dispose = port.lifecycle.onTurnEnd(async () => undefined);
- dispose();
- assert.deepEqual(events, ["agent_end"]);
-});
-
-test("createPiHarnessPort forwards Pi context prompt as retrieval query", async () => {
- let contextHandler: ((event: unknown, ctx: unknown) => Promise | unknown) | undefined;
- const pi = {
- on(event: string, handler: unknown) {
- if (event === "context") {
- contextHandler = handler as (event: unknown, ctx: unknown) => Promise | unknown;
- }
- return () => undefined;
- },
- };
- const port = createPiHarnessPort(pi, {
- model: {
- async complete() {
- return { message: { role: "assistant", content: "done" } };
+ mode: "json",
+ model,
+ sessionManager: { getSessionId: () => "chat-1" },
+ modelRegistry: {
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "host-oauth" }),
},
+ getThinkingLevel: () => "high",
},
- });
-
- let seen: { sessionId?: string; query?: string } | undefined;
- port.lifecycle.onPromptBuild(async (event) => {
- seen = event;
- return { systemPrompt: "rules", preludePrompt: "index" };
- });
- assert.ok(contextHandler);
- await contextHandler!({ sessionId: "p1", prompt: "how do I publish?" }, {});
+ );
- assert.deepEqual(seen, { sessionId: "p1", query: "how do I publish?" });
+ const binding = await port.resolveModel();
+ assert.strictEqual(binding.model, model);
+ assert.equal(binding.thinkingLevel, "high");
+ assert.equal(binding.transport, "sse");
+ assert.equal(binding.sessionId, "memflywheel:chat-1");
+ const response = await (
+ await binding.streamFn(
+ binding.model,
+ { messages: [] },
+ { apiKey: await binding.getApiKey?.("x") },
+ )
+ ).result();
+ assert.equal(capturedApiKey, "host-oauth");
+ assert.equal(response.usage.totalTokens, 17);
+ assert.equal(response.responseId, "resp_native");
});
-test("createPiHarnessPort forwards the latest Pi context user message as retrieval query", async () => {
- let contextHandler: ((event: unknown, ctx: unknown) => Promise | unknown) | undefined;
- const pi = {
- on(event: string, handler: unknown) {
- if (event === "context") {
- contextHandler = handler as (event: unknown, ctx: unknown) => Promise | unknown;
- }
- return () => undefined;
- },
- };
- const port = createPiHarnessPort(pi, {
- model: {
- async complete() {
- return { message: { role: "assistant", content: "done" } };
- },
+test("Pi context forwards the latest user query into progressive recall", async () => {
+ const { api, handlers } = fakePi();
+ const port = createPiHarnessPort(api, {
+ resolveModel: async () => {
+ throw new Error("unused");
},
});
-
- let seen: { sessionId?: string; query?: string } | undefined;
+ let query: string | undefined;
port.lifecycle.onPromptBuild(async (event) => {
- seen = event;
- return { systemPrompt: "rules", preludePrompt: "index" };
+ query = event.query;
+ return { preludePrompt: "memory index" };
});
- assert.ok(contextHandler);
- await contextHandler!(
- {
- messages: [
- { role: "user", content: [{ type: "text", text: "old question" }] },
- { role: "assistant", content: [{ type: "text", text: "old answer" }] },
- { role: "user", content: [{ type: "text", text: "how do I publish now?" }] },
- ],
- },
- { sessionManager: { getSessionId: () => "p2" } },
- );
-
- assert.deepEqual(seen, { sessionId: "p2", query: "how do I publish now?" });
-});
-
-test("createPiHarnessPort isolates background model session from Pi chat continuation", async () => {
- let turnHandler: PiExtensionHandler | undefined;
- const model = { id: "gpt-5.5", provider: "openai-codex" };
- const seenSessionIds: unknown[] = [];
- const completeSimple: PiCompleteSimple = async (_model, _context, options) => {
- seenSessionIds.push(options?.sessionId);
- return {
- role: "assistant",
- content: [{ type: "text", text: "done" }],
- };
- };
- const pi = {
- on(event: string, handler: PiExtensionHandler) {
- if (event === "agent_end") turnHandler = handler;
- return () => undefined;
- },
- };
- const port = createPiHarnessPort(pi, { completeSimple, piModel: model });
- port.lifecycle.onTurnEnd(async () => {
- await port.model.complete({ messages: [], tools: [] });
+ const result = await handlers.get("context")?.({
+ messages: [
+ { role: "user", content: "old" },
+ { role: "assistant", content: [{ type: "text", text: "ok" }] },
+ { role: "user", content: [{ type: "text", text: "latest query" }] },
+ ],
});
-
- assert.ok(turnHandler);
- await turnHandler({ sessionId: "chat-1", messages: [] }, {});
-
- assert.deepEqual(seenSessionIds, ["memflywheel:chat-1"]);
+ assert.equal(query, "latest query");
+ assert.equal((result as { messages: unknown[] }).messages.length, 4);
});
-test("createPiHarnessPort runs host-native sync hooks after lifecycle handling", async () => {
- const handlers = new Map Promise | unknown>();
- const calls: string[] = [];
- const pi = {
- on(event: string, handler: unknown) {
- handlers.set(event, handler as (event: unknown, ctx: unknown) => Promise | unknown);
- return () => undefined;
- },
- };
- const port = createPiHarnessPort(pi, {
- model: {
- async complete() {
- return { message: { role: "assistant", content: "done" } };
- },
- },
- afterPromptBuild: () => {
- calls.push("sync:prompt");
- },
- afterTurnEnd: () => {
- calls.push("sync:turn");
- },
- afterSessionEnd: () => {
- calls.push("sync:session");
+test("Pi lifecycle hooks run only after host handlers complete", async () => {
+ const { api, handlers } = fakePi();
+ const order: string[] = [];
+ const port = createPiHarnessPort(api, {
+ resolveModel: async () => {
+ throw new Error("unused");
},
+ afterTurnEnd: () => void order.push("sync"),
});
-
- port.lifecycle.onPromptBuild(async () => {
- calls.push("prompt");
- return { preludePrompt: "index" };
- });
- port.lifecycle.onTurnEnd(async () => {
- calls.push("turn");
- });
- port.lifecycle.onSessionEnd(async () => {
- calls.push("session");
- });
-
- const contextHandler = handlers.get("context");
- const turnHandler = handlers.get("agent_end");
- const sessionHandler = handlers.get("session_shutdown");
- assert.ok(contextHandler);
- assert.ok(turnHandler);
- assert.ok(sessionHandler);
-
- await contextHandler({ sessionId: "p1", prompt: "release?" }, {});
- await turnHandler({ sessionId: "p1", messages: [] }, {});
- await sessionHandler({ sessionId: "p1" }, {});
-
- assert.deepEqual(calls, [
- "prompt",
- "sync:prompt",
- "turn",
- "sync:turn",
- "session",
- "sync:session",
- ]);
+ port.lifecycle.onTurnEnd(async () => void order.push("turn"));
+ await handlers.get("agent_end")?.({ messages: [] }, {});
+ assert.deepEqual(order, ["turn", "sync"]);
});
diff --git a/packages/adapters/src/pi-port.ts b/packages/adapters/src/pi-port.ts
index 5a2288e..cdd2f1e 100644
--- a/packages/adapters/src/pi-port.ts
+++ b/packages/adapters/src/pi-port.ts
@@ -1,16 +1,12 @@
-import type {
- CanonicalModelCompletion,
- CanonicalModelMessage,
- CanonicalModelRequest,
- CanonicalModelResponse,
- CanonicalToolDefinition,
-} from "@memflywheel/model";
+import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
+import type { PiAgentModelBinding, ResolvePiAgentModel } from "@memflywheel/sdk";
import {
createCapabilitySet,
type Dispose,
type HostCapability,
type HostHarnessPort,
+ type HostMessage,
type HostPromptBuildResult,
} from "./harness-port.js";
import type { MemFlywheelMessage } from "./adapter.js";
@@ -49,27 +45,10 @@ export interface PiToolResultMessage {
timestamp?: number;
}
-export interface PiAssistantMessage {
- role: "assistant";
- content: Array;
- stopReason?: string;
- timestamp?: number;
-}
+export type PiAssistantMessage = AssistantMessage;
export type PiAgentMessage = PiUserMessage | PiAssistantMessage | PiToolResultMessage;
-export interface PiModelContext {
- systemPrompt?: string;
- messages: PiAgentMessage[];
- tools?: PiToolDefinition[];
-}
-
-export interface PiToolDefinition {
- name: string;
- description: string;
- parameters: CanonicalToolDefinition["inputSchema"];
-}
-
export interface PiModelAuthResult {
ok: boolean;
apiKey?: string;
@@ -78,6 +57,7 @@ export interface PiModelAuthResult {
}
export interface PiExtensionContextLike {
+ mode?: "tui" | "rpc" | "json" | "print";
cwd?: string;
model?: unknown;
signal?: AbortSignal;
@@ -101,19 +81,15 @@ export interface PiExtensionApiLike {
off?(event: string, handler: PiExtensionHandler): void;
}
-export type PiCompleteSimple = (
- model: unknown,
- context: PiModelContext,
- options?: Record,
-) => Promise;
+export type PiStreamSimple = PiAgentModelBinding["streamFn"];
export type PiSessionIdResolver =
string | ((input: { event?: unknown; context?: PiExtensionContextLike }) => string | undefined);
export type PiLifecycleAfterHook = () => void | Promise;
-export interface CreatePiModelCompletionOptions {
- completeSimple: PiCompleteSimple;
+export interface CreatePiAgentModelResolverOptions {
+ streamSimple: PiStreamSimple;
/** Explicit Pi model; when absent, the current ExtensionContext model is used. */
model?: unknown;
/** Latest Pi ExtensionContext, captured by lifecycle events. */
@@ -123,10 +99,9 @@ export interface CreatePiModelCompletionOptions {
}
export interface CreatePiHarnessPortOptions {
- /** Use an already canonical host-owned model channel. */
- model?: CanonicalModelCompletion;
- /** Use Pi's native completeSimple(model, context, options) function. */
- completeSimple?: PiCompleteSimple;
+ resolveModel?: ResolvePiAgentModel;
+ /** Use Pi's native streamSimple(model, context, options) function. */
+ streamSimple?: PiStreamSimple;
/** Explicit Pi model for background MemFlywheel loops. Defaults to ctx.model. */
piModel?: unknown;
/** Resolve the MemFlywheel session id from Pi event/context. Defaults to Pi session id, else "pi". */
@@ -218,98 +193,9 @@ function piUserMessage(content: string): PiUserMessage {
};
}
-function piMessageFromCanonical(
- message: CanonicalModelMessage,
- toolNamesById: Map,
-): PiAgentMessage {
- if (message.role === "tool") {
- return {
- role: "toolResult",
- toolCallId: message.toolCallId ?? "",
- toolName: message.toolCallId ? (toolNamesById.get(message.toolCallId) ?? "tool") : "tool",
- content: message.content ? [{ type: "text", text: message.content }] : [],
- isError: false,
- timestamp: Date.now(),
- };
- }
-
- if (message.role !== "assistant") {
- return {
- role: "user",
- content: message.content ? [{ type: "text", text: message.content }] : [],
- timestamp: Date.now(),
- };
- }
-
- const content: Array = [];
- if (message.content) content.push({ type: "text", text: message.content });
- for (const call of message.toolCalls ?? []) {
- toolNamesById.set(call.id, call.name);
- content.push({
- type: "toolCall",
- id: call.id,
- name: call.name,
- arguments: isRecord(call.input) ? call.input : { value: call.input },
- });
- }
- return {
- role: "assistant",
- content,
- timestamp: Date.now(),
- };
-}
-
-function piToolFromCanonical(tool: CanonicalToolDefinition): PiToolDefinition {
- return {
- name: tool.name,
- description: tool.description,
- parameters: tool.inputSchema,
- };
-}
-
-function piContextFromCanonical(req: CanonicalModelRequest): PiModelContext {
- const systemPrompt = req.messages
- .filter((message) => message.role === "system" && message.content)
- .map((message) => message.content)
- .join("\n\n");
- const toolNamesById = new Map();
- const messages = req.messages
- .filter((message) => message.role !== "system")
- .map((message) => piMessageFromCanonical(message, toolNamesById));
-
- return {
- ...(systemPrompt ? { systemPrompt } : {}),
- messages,
- tools: req.tools.map(piToolFromCanonical),
- };
-}
-
-function canonicalResponseFromPi(message: PiAssistantMessage): CanonicalModelResponse {
- const toolCalls = message.content
- .filter(
- (part): part is PiToolCallContent =>
- isRecord(part) &&
- part.type === "toolCall" &&
- typeof part.id === "string" &&
- typeof part.name === "string" &&
- isRecord(part.arguments),
- )
- .map((part) => ({
- id: part.id,
- name: part.name,
- input: part.arguments,
- }));
- const out: CanonicalModelMessage = {
- role: "assistant",
- content: textFromPiContent(message.content),
- };
- if (toolCalls.length > 0) out.toolCalls = toolCalls;
- return { message: out, finishReason: message.stopReason };
-}
-
-export function canonicalMessagesFromPi(messages: unknown): CanonicalModelMessage[] {
+export function hostMessagesFromPi(messages: unknown): HostMessage[] {
if (!Array.isArray(messages)) return [];
- const out: CanonicalModelMessage[] = [];
+ const out: HostMessage[] = [];
for (const raw of messages) {
if (!isRecord(raw)) continue;
if (raw.role === "toolResult") {
@@ -334,7 +220,7 @@ export function canonicalMessagesFromPi(messages: unknown): CanonicalModelMessag
)
.map((part) => ({ id: part.id, name: part.name, input: part.arguments }))
: [];
- const message: CanonicalModelMessage = {
+ const message: HostMessage = {
role: raw.role,
content: textFromPiContent(raw.content),
};
@@ -345,16 +231,16 @@ export function canonicalMessagesFromPi(messages: unknown): CanonicalModelMessag
}
export function memScribeMessagesFromPi(messages: unknown): MemFlywheelMessage[] {
- const canonical = canonicalMessagesFromPi(messages);
+ const hostMessages = hostMessagesFromPi(messages);
const outputs = new Map();
- for (const message of canonical) {
+ for (const message of hostMessages) {
if (message.role === "tool" && message.toolCallId) {
outputs.set(message.toolCallId, message.content);
}
}
const out: MemFlywheelMessage[] = [];
- for (const message of canonical) {
+ for (const message of hostMessages) {
if (message.role !== "user" && message.role !== "assistant") continue;
const text = typeof message.content === "string" ? message.content.trim() : "";
const toolCalls =
@@ -407,41 +293,36 @@ function bindPiEvent(pi: PiExtensionApiLike, event: string, handler: PiExtension
return () => undefined;
}
-export function createPiModelCompletion(
- options: CreatePiModelCompletionOptions,
-): CanonicalModelCompletion {
- return {
- async complete(req: CanonicalModelRequest): Promise {
- const ctx = options.getContext?.();
- const model = options.model ?? ctx?.model;
- if (!model) {
- throw new Error("Pi model completion requires a current Pi model");
- }
-
- const auth = await ctx?.modelRegistry?.getApiKeyAndHeaders?.(model);
- if (auth && !auth.ok) {
- throw new Error(`Pi model auth unavailable: ${auth.error ?? "unknown error"}`);
- }
-
- const thinkingLevel = ctx?.getThinkingLevel?.();
- const requestOptions: Record = {
- signal: req.signal ?? ctx?.signal,
- };
- if (auth?.apiKey) requestOptions.apiKey = auth.apiKey;
- if (auth?.headers) requestOptions.headers = auth.headers;
- if (typeof thinkingLevel === "string" && thinkingLevel !== "off") {
- requestOptions.reasoning = thinkingLevel;
- }
- const sessionId = options.getSessionId?.();
- if (sessionId) requestOptions.sessionId = sessionId;
-
- const response = await options.completeSimple(
- model,
- piContextFromCanonical(req),
- requestOptions,
- );
- return canonicalResponseFromPi(response);
- },
+export function createPiAgentModelResolver(
+ options: CreatePiAgentModelResolverOptions,
+): ResolvePiAgentModel {
+ return async (): Promise => {
+ const ctx = options.getContext?.();
+ const model = options.model ?? ctx?.model;
+ if (!model) {
+ throw new Error("Pi memory agent requires a current Pi model");
+ }
+
+ const auth = await ctx?.modelRegistry?.getApiKeyAndHeaders?.(model);
+ if (auth && !auth.ok) {
+ throw new Error(`Pi model auth unavailable: ${auth.error ?? "unknown error"}`);
+ }
+
+ const thinkingLevel = ctx?.getThinkingLevel?.();
+ const sessionId = options.getSessionId?.();
+ const api = isRecord(model) ? readString(model, "api") : undefined;
+ const shortLivedMode = ctx?.mode === "print" || ctx?.mode === "json";
+ return {
+ model: model as Model,
+ streamFn: options.streamSimple,
+ ...(auth?.apiKey ? { getApiKey: () => auth.apiKey } : {}),
+ ...(auth?.headers ? { request: { headers: auth.headers } } : {}),
+ ...(typeof thinkingLevel === "string"
+ ? { thinkingLevel: thinkingLevel as PiAgentModelBinding["thinkingLevel"] }
+ : {}),
+ ...(shortLivedMode && api === "openai-codex-responses" ? { transport: "sse" as const } : {}),
+ ...(sessionId ? { sessionId } : {}),
+ };
};
}
@@ -498,25 +379,24 @@ export function createPiHarnessPort(
lastSessionId = resolvePiSessionId(options.sessionId, event, ctx ?? lastContext);
};
- const model =
- options.model ??
- (options.completeSimple
- ? createPiModelCompletion({
- completeSimple: options.completeSimple,
+ const resolveModel =
+ options.resolveModel ??
+ (options.streamSimple
+ ? createPiAgentModelResolver({
+ streamSimple: options.streamSimple,
model: options.piModel,
getContext: () => lastContext,
getSessionId: () => isolatedBackgroundModelSessionId(lastSessionId),
})
: undefined);
- if (!model) {
- throw new Error("createPiHarnessPort requires either a canonical model or Pi completeSimple");
+ if (!resolveModel) {
+ throw new Error("createPiHarnessPort requires either resolveModel or Pi streamSimple");
}
const capabilities: HostCapability[] = [
"prompt-build",
"turn-end",
"session-end",
- "single-tool-completion",
"agentic-tool-loop",
"tool-trajectory",
];
@@ -540,7 +420,7 @@ export function createPiHarnessPort(
rememberContext(event, ctx);
await handler({
sessionId: lastSessionId ?? "pi",
- messages: canonicalMessagesFromPi(isRecord(event) ? event.messages : undefined),
+ messages: hostMessagesFromPi(isRecord(event) ? event.messages : undefined),
});
await options.afterTurnEnd?.();
});
@@ -568,7 +448,7 @@ export function createPiHarnessPort(
return {
name: "pi",
capabilities: createCapabilitySet(capabilities),
- model,
+ resolveModel,
lifecycle,
telemetry: {
onToolCall(handler) {
diff --git a/packages/adapters/tsconfig.json b/packages/adapters/tsconfig.json
index afbdb24..540b001 100644
--- a/packages/adapters/tsconfig.json
+++ b/packages/adapters/tsconfig.json
@@ -5,5 +5,5 @@
"outDir": "dist"
},
"include": ["src/**/*.ts"],
- "references": [{ "path": "../model" }, { "path": "../sdk" }, { "path": "../skills" }]
+ "references": [{ "path": "../embeddings" }, { "path": "../sdk" }, { "path": "../skills" }]
}
diff --git a/packages/core/NOTICE b/packages/core/NOTICE
index c590cea..3bf0ff6 100644
--- a/packages/core/NOTICE
+++ b/packages/core/NOTICE
@@ -2,6 +2,11 @@ MemFlywheel
Copyright 2026 iFLYTEK CO., LTD.
This product includes software developed at:
+- proper-lockfile (https://github.com/moxystudio/node-proper-lockfile), licensed under MIT License
+- graceful-fs (https://github.com/isaacs/node-graceful-fs), licensed under ISC License
+- retry (https://github.com/tim-kos/node-retry), licensed under MIT License
+- signal-exit (https://github.com/tapjs/signal-exit), licensed under ISC License
+- DefinitelyTyped proper-lockfile type definitions (https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/proper-lockfile), licensed under MIT License
- TypeBox (https://github.com/sinclairzx81/typebox), licensed under MIT License
- TypeScript (https://www.typescriptlang.org/), licensed under Apache License 2.0
- DefinitelyTyped Node.js type definitions (https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node), licensed under MIT License
diff --git a/packages/core/THIRD_PARTY_LICENSES b/packages/core/THIRD_PARTY_LICENSES
index aa7c6fd..3a6420c 100644
--- a/packages/core/THIRD_PARTY_LICENSES
+++ b/packages/core/THIRD_PARTY_LICENSES
@@ -1,12 +1,39 @@
Third-Party Licenses
====================
-This file lists third-party npm packages used by the MemFlywheel source
-workspace for build, test, and type-checking. The MemFlywheel packages
-themselves are licensed under the repository LICENSE file.
+This file lists third-party npm packages used at runtime or for source
+validation, type-checking, and builds. The MemFlywheel packages themselves are
+licensed under the repository LICENSE file.
-Published MemFlywheel packages do not bundle third-party runtime libraries.
-The packages below are used for source validation, type-checking, and builds.
+Package: proper-lockfile
+Version: 4.1.2
+Homepage: https://github.com/moxystudio/node-proper-lockfile
+License: MIT
+Copyright: Copyright (c) 2018 Made With MOXY Lda
+
+Package: graceful-fs
+Version: 4.2.11
+Homepage: https://github.com/isaacs/node-graceful-fs
+License: ISC
+Copyright: Copyright (c) 2011-2022 Isaac Z. Schlueter, Ben Noordhuis, and Contributors
+
+Package: retry
+Version: 0.12.0
+Homepage: https://github.com/tim-kos/node-retry
+License: MIT
+Copyright: Copyright (c) 2011 Tim Koschützki and Felix Geisendörfer
+
+Package: signal-exit
+Version: 3.0.7
+Homepage: https://github.com/tapjs/signal-exit
+License: ISC
+Copyright: Copyright (c) 2015 Contributors
+
+Package: @types/proper-lockfile
+Version: 4.1.4
+Homepage: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/proper-lockfile
+License: MIT
+Copyright: Copyright (c) Microsoft Corporation
Package: typebox
Version: 1.3.1
diff --git a/packages/core/package.json b/packages/core/package.json
index b6df5e8..8543089 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@memflywheel/core",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"description": "File-backed long-term memory kernel for agent runtimes.",
"license": "Apache-2.0",
@@ -48,6 +48,10 @@
},
"devDependencies": {
"@types/node": "^22.20.1",
+ "@types/proper-lockfile": "^4.1.4",
"typescript": "^5.5.4"
+ },
+ "dependencies": {
+ "proper-lockfile": "^4.1.2"
}
}
diff --git a/packages/core/src/dream.test.ts b/packages/core/src/dream.test.ts
index 34b8891..3d9ac47 100644
--- a/packages/core/src/dream.test.ts
+++ b/packages/core/src/dream.test.ts
@@ -301,25 +301,27 @@ test("runDreamSession stamps the gate state and resets the session count", async
}
});
-test("a locked dream pass does not run and does not stamp the gate state", async () => {
+test("a contended dream pass waits for the writer and then runs", async () => {
const root = await makeRoot();
try {
const ctx = ctxFor(root);
await bumpDreamSessions(root, 3);
- const { acquireLock, releaseLock } = await import("./lock.js");
+ const { acquireLock } = await import("./lock.js");
const held = await acquireLock(root, "other");
- assert.equal(held.acquired, true);
- try {
- const result = await runDreamSession({ ctx });
- assert.equal(result.ran, false);
- assert.equal(result.reason, "locked");
- // State untouched: no stamp, counter preserved.
- const st = await readDreamState(root);
- assert.equal(st.lastConsolidatedAt, null);
- assert.equal(st.sessionsSince, 3);
- } finally {
- await releaseLock(root);
- }
+ let settled = false;
+ const pending = runDreamSession({ ctx }).then((result) => {
+ settled = true;
+ return result;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ assert.equal(settled, false);
+ await held.release();
+ const result = await pending;
+ assert.equal(result.ran, true);
+ assert.equal(result.reason, "ok");
+ const st = await readDreamState(root);
+ assert.notEqual(st.lastConsolidatedAt, null);
+ assert.equal(st.sessionsSince, 0);
} finally {
await cleanup(root);
}
diff --git a/packages/core/src/dream.ts b/packages/core/src/dream.ts
index 124cb8e..6a6dfa3 100644
--- a/packages/core/src/dream.ts
+++ b/packages/core/src/dream.ts
@@ -219,12 +219,9 @@ export interface DreamSessionResult {
*/
export async function runDreamSession(opts: RunDreamSessionOptions): Promise {
const { ctx, runner, coordination, refuseSecrets } = opts;
- const { acquireLock, releaseLock } = await import("./lock.js");
+ const { acquireLock } = await import("./lock.js");
const handle = await acquireLock(ctx.root, "dream");
- if (!handle.acquired) {
- return { ran: false, reason: "locked", deterministic: [], changed: [], deleted: [] };
- }
try {
await ensureMemoryDir(ctx.root);
@@ -288,7 +285,7 @@ export async function runDreamSession(opts: RunDreamSessionOptions): Promise {
assert.equal(cleaned[1]!.timestamp, "2023-05-08");
});
+test("cleanMessages redacts private spans from text and tool payloads", () => {
+ const cleaned = cleanMessages([
+ { role: "user", text: "keep user-secret visible" },
+ {
+ role: "assistant",
+ text: "tool assistant-secret",
+ toolCalls: [
+ {
+ name: "read",
+ input: { path: "private-path" },
+ output: ["private-output"],
+ },
+ ],
+ },
+ ]);
+ assert.equal(cleaned[0]!.text, "keep [REDACTED] visible");
+ assert.equal(cleaned[1]!.text, "tool [REDACTED]");
+ assert.deepEqual(cleaned[1]!.toolCalls?.[0]?.input, { path: "[REDACTED]" });
+ assert.deepEqual(cleaned[1]!.toolCalls?.[0]?.output, ["[REDACTED]"]);
+});
+
+test("runExtractionSession never persists private spans in source traces", async () => {
+ const root = await makeRoot();
+ try {
+ await writeRaw(
+ root,
+ ".memflywheel/sources/legacy.jsonl",
+ '{"role":"user","text":"legacy legacy-secret"}\n',
+ );
+ let observed = "";
+ const agent: ExtractionAgentRunner = async (input) => {
+ observed = JSON.stringify(input.messages);
+ return { changed: [] };
+ };
+ await runExtractionSession({
+ ctx: ctxFor(root),
+ agent,
+ messages: [
+ { role: "user", text: "public source-secret" },
+ {
+ role: "assistant",
+ text: "done",
+ toolCalls: [
+ {
+ name: "read",
+ input: "input-secret",
+ output: "output-secret",
+ },
+ ],
+ },
+ ],
+ sessionId: "private-source-trace",
+ cursorStore: createMemoryCursorStore(),
+ });
+ assert.doesNotMatch(observed, /source-secret|input-secret|output-secret/);
+ const sourceDir = path.join(root, ".memflywheel", "sources");
+ const sourceNames = await readdir(sourceDir);
+ assert.equal(sourceNames.length, 2);
+ const sources = await Promise.all(
+ sourceNames.map((sourceName) => readFile(path.join(sourceDir, sourceName), "utf8")),
+ );
+ const source = sources.join("\n");
+ assert.doesNotMatch(source, /legacy-secret|source-secret|input-secret|output-secret/);
+ assert.match(source, /\[REDACTED\]/);
+ } finally {
+ await cleanup(root);
+ }
+});
+
test("relocateRootFiles moves stray root .md into typed dir", async () => {
const root = await makeRoot();
try {
diff --git a/packages/core/src/extract.ts b/packages/core/src/extract.ts
index 03dbea3..ec662a7 100644
--- a/packages/core/src/extract.ts
+++ b/packages/core/src/extract.ts
@@ -26,6 +26,8 @@ import { getTypedMemoryPath, normalizeRelativePath } from "./paths.js";
import { readMemoryFrontmatterHeader } from "./internal-frontmatter.js";
import { scanAllMemoryFiles, scanMemoryFiles, formatManifest } from "./scan.js";
import { syncMemoryIndex } from "./index-file.js";
+import { redactPrivateSpans } from "./privacy.js";
+import { atomicWriteFile } from "./atomic.js";
import {
type FileTool,
type FileToolContext,
@@ -38,7 +40,6 @@ export const EXTRACTION_CONTEXT_WINDOW_SIZE = 6;
export const EXTRACTION_MAX_MESSAGES = 40;
export enum ExtractionResult {
- Queued = "queued",
Completed = "completed",
Skipped = "skipped",
Failed = "failed",
@@ -166,6 +167,27 @@ export function isPreludeText(text: string): boolean {
return PRELUDE_PATTERNS.some((re) => re.test(trimmed));
}
+function redactPrivateValue(value: unknown): unknown {
+ if (typeof value === "string") return redactPrivateSpans(value);
+ if (Array.isArray(value)) return value.map(redactPrivateValue);
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, child]) => [key, redactPrivateValue(child)]),
+ );
+ }
+ return value;
+}
+
+function redactPrivateToolCalls(
+ toolCalls: ExtractionToolCall[] | undefined,
+): ExtractionToolCall[] | undefined {
+ return toolCalls?.map((call) => ({
+ name: call.name,
+ input: redactPrivateValue(call.input),
+ output: redactPrivateValue(call.output),
+ }));
+}
+
/**
* Strip blocks + prelude patterns from user turns; keep
* assistant turns verbatim. Empty user turns are dropped.
@@ -174,13 +196,17 @@ export function cleanMessages(messages: ExtractionMessage[]): ExtractionMessage[
const out: ExtractionMessage[] = [];
for (const m of messages) {
if (m.role === "assistant") {
- out.push(m);
+ out.push({
+ ...m,
+ text: redactPrivateSpans(m.text),
+ toolCalls: redactPrivateToolCalls(m.toolCalls),
+ });
continue;
}
- const cleaned = stripSystemReminderBlocks(m.text);
+ const cleaned = redactPrivateSpans(stripSystemReminderBlocks(m.text));
if (!cleaned || isPreludeText(cleaned)) continue;
const rebuilt: ExtractionMessage = { role: "user", text: cleaned };
- if (m.toolCalls) rebuilt.toolCalls = m.toolCalls;
+ if (m.toolCalls) rebuilt.toolCalls = redactPrivateToolCalls(m.toolCalls);
if (m.timestamp) rebuilt.timestamp = m.timestamp;
out.push(rebuilt);
}
@@ -250,6 +276,21 @@ async function appendSourceTrace(
return { relativePath, absolutePath, startLine, endLine };
}
+async function redactExistingSourceTraces(root: string): Promise {
+ const sourceDir = path.join(root, SOURCE_TRACE_DIR);
+ const entries = await readdir(sourceDir, { withFileTypes: true }).catch((error) => {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
+ throw error;
+ });
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
+ const absolutePath = path.join(sourceDir, entry.name);
+ const current = await readFile(absolutePath, "utf8");
+ const redacted = redactPrivateSpans(current);
+ if (redacted !== current) await atomicWriteFile(absolutePath, redacted);
+ }
+}
+
/**
* Select the message window for extraction (cursor as an index into the array).
* cursorIndex is the index of the last already-processed message; null = first run.
@@ -377,27 +418,6 @@ export async function relocateRootFiles(ctx: StorageContext): Promise
// ---- Full lifecycle ----
-const pendingExtractions = new Map Promise>();
-let draining = false;
-
-async function drainPending(): Promise {
- if (draining || pendingExtractions.size === 0) return;
- draining = true;
- try {
- while (pendingExtractions.size > 0) {
- const next = pendingExtractions.entries().next().value as
- [string, () => Promise] | undefined;
- if (!next) break;
- const [sessionId, run] = next;
- pendingExtractions.delete(sessionId);
- const result = await run();
- if (result === ExtractionResult.Queued) break;
- }
- } finally {
- draining = false;
- }
-}
-
export interface RunExtractionSessionOptions {
ctx: StorageContext;
/** The injected subagent driver (SDK's tool-calling loop). It writes via the tools. */
@@ -411,7 +431,7 @@ export interface RunExtractionSessionOptions {
/**
* Full extraction session closure — core owns everything except the LLM:
- * 1 acquireLock (else enqueue → Queued)
+ * 1 acquire the cross-process root lock (bounded wait; timeout throws)
* 2 relocateRootFiles
* 3 select window via cursor (over cleaned messages)
* 4 before = scanMemoryFiles → manifest
@@ -420,7 +440,7 @@ export interface RunExtractionSessionOptions {
* 6 relocateRootFiles again (catch any root-level write the subagent made)
* 7 after = scanMemoryFiles → syncMemoryIndex
* 8 advance cursor ONLY on success; stamp .last-extraction
- * 9 releaseLock; drain pending queue
+ * 9 release the root lock
*
* A thrown agent runner (e.g. network failure) ⇒ Failed, and the cursor does
* NOT advance, so the window is retried next turn. Per-tool failures are
@@ -430,16 +450,13 @@ export async function runExtractionSession(
opts: RunExtractionSessionOptions,
): Promise {
const { ctx, agent, messages, sessionId, cursorStore, refuseSecrets } = opts;
- const { acquireLock, releaseLock } = await import("./lock.js");
+ const { acquireLock } = await import("./lock.js");
const handle = await acquireLock(ctx.root, "extract");
- if (!handle.acquired) {
- pendingExtractions.set(sessionId, () => runExtractionSession(opts));
- return ExtractionResult.Queued;
- }
try {
await relocateRootFiles(ctx);
+ await redactExistingSourceTraces(ctx.root);
const cleaned = cleanMessages(messages);
const cursor = cursorStore.get(sessionId);
@@ -477,8 +494,7 @@ export async function runExtractionSession(
const changedCount = countChanges(before, after);
return changedCount > 0 ? ExtractionResult.Completed : ExtractionResult.Skipped;
} finally {
- await releaseLock(ctx.root);
- void drainPending();
+ await handle.release();
}
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 17348fc..addbc8f 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -125,14 +125,7 @@ export {
} from "./file-tools.js";
// Lock
-export {
- LOCK_TIMEOUT_MS,
- LOCK_FILE,
- type LockHandle,
- acquireLock,
- releaseLock,
- withLock,
-} from "./lock.js";
+export { LOCK_TIMEOUT_MS, LOCK_FILE, type LockHandle, acquireLock, withLock } from "./lock.js";
// Atomic + audit
export { atomicWriteFile, appendFileLine } from "./atomic.js";
diff --git a/packages/core/src/lock.test.ts b/packages/core/src/lock.test.ts
index 6544e8c..2aaa258 100644
--- a/packages/core/src/lock.test.ts
+++ b/packages/core/src/lock.test.ts
@@ -1,62 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { readFile, writeFile } from "node:fs/promises";
+import { mkdir, utimes } from "node:fs/promises";
import path from "node:path";
-import { acquireLock, releaseLock, withLock, LOCK_FILE } from "./lock.js";
+import { acquireLock, withLock, LOCK_FILE, LOCK_TIMEOUT_MS } from "./lock.js";
import { makeRoot, cleanup } from "./test-helpers.js";
-test("acquireLock is exclusive and releaseLock frees it", async () => {
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+test("acquireLock waits for the current writer and then acquires", async () => {
const root = await makeRoot();
try {
const first = await acquireLock(root, "a");
- assert.equal(first.acquired, true);
- const second = await acquireLock(root, "b");
- assert.equal(second.acquired, false);
- await releaseLock(root);
- const third = await acquireLock(root, "c");
- assert.equal(third.acquired, true);
- await releaseLock(root);
+ let acquired = false;
+ const secondPromise = acquireLock(root, "b").then((handle) => {
+ acquired = true;
+ return handle;
+ });
+ await delay(50);
+ assert.equal(acquired, false);
+ await first.release();
+ const second = await secondPromise;
+ assert.equal(second.owner, "b");
+ await second.release();
} finally {
await cleanup(root);
}
});
-test("acquireLock reclaims a stale lock (dead pid)", async () => {
+test("acquireLock reclaims a stale proper-lockfile directory", async () => {
const root = await makeRoot();
try {
const lockPath = path.join(root, LOCK_FILE);
- await writeFile(
- lockPath,
- JSON.stringify({ pid: 2147483646, owner: "ghost", startedAt: new Date().toISOString() }),
- );
+ await mkdir(lockPath);
+ const stale = new Date(Date.now() - LOCK_TIMEOUT_MS - 1_000);
+ await utimes(lockPath, stale, stale);
const handle = await acquireLock(root, "live");
- assert.equal(handle.acquired, true);
- const content = JSON.parse(await readFile(lockPath, "utf8"));
- assert.equal(content.owner, "live");
- await releaseLock(root);
+ assert.equal(handle.owner, "live");
+ await handle.release();
} finally {
await cleanup(root);
}
});
-test("withLock runs fn and releases, returns null when held", async () => {
+test("withLock serializes concurrent critical sections", async () => {
const root = await makeRoot();
try {
- let ran = false;
- const result = await withLock(root, "owner", async () => {
- ran = true;
- // While held, a nested acquire should fail.
- const nested = await acquireLock(root, "nested");
- assert.equal(nested.acquired, false);
- return 42;
+ const events: string[] = [];
+ const first = withLock(root, "first", async () => {
+ events.push("first:start");
+ await delay(75);
+ events.push("first:end");
+ return 1;
+ });
+ await delay(10);
+ const second = withLock(root, "second", async () => {
+ events.push("second:start");
+ events.push("second:end");
+ return 2;
});
- assert.equal(ran, true);
- assert.equal(result, 42);
- // Lock is released; can re-acquire.
- const after = await acquireLock(root, "again");
- assert.equal(after.acquired, true);
- await releaseLock(root);
+ assert.deepEqual(await Promise.all([first, second]), [1, 2]);
+ assert.deepEqual(events, ["first:start", "first:end", "second:start", "second:end"]);
} finally {
await cleanup(root);
}
diff --git a/packages/core/src/lock.ts b/packages/core/src/lock.ts
index cb0eda7..9fde8fb 100644
--- a/packages/core/src/lock.ts
+++ b/packages/core/src/lock.ts
@@ -1,110 +1,48 @@
-/**
- * Per-root write mutex with explicit acquire/release semantics.
- *
- * Lock file is JSON { pid, owner, startedAt }. A lock is stale when the holder
- * process is gone (process.kill(pid,0) throws) OR it has exceeded the timeout.
- * Acquisition uses an exclusive create (flag "wx").
- */
+/** Cross-process write mutex for one memory root. */
-import { readFile, writeFile, unlink } from "node:fs/promises";
import path from "node:path";
+import lockfile from "proper-lockfile";
+
import { ensureMemoryDir } from "./paths.js";
export const LOCK_TIMEOUT_MS = 3 * 60 * 1000;
export const LOCK_FILE = ".memory-task-lock";
export interface LockHandle {
- acquired: boolean;
lockPath: string;
-}
-
-interface LockContent {
- pid: number;
owner: string;
- startedAt: string;
-}
-
-function isProcessAlive(pid: number): boolean {
- try {
- process.kill(pid, 0);
- return true;
- } catch {
- return false;
- }
+ release(): Promise;
}
+/**
+ * Acquire the root lock, waiting for the current writer instead of reporting a
+ * fake queued success. Exhausting the bounded wait throws ELOCKED to the host.
+ */
export async function acquireLock(root: string, owner = "memory-task"): Promise {
await ensureMemoryDir(root);
const lockPath = path.join(root, LOCK_FILE);
-
- try {
- const existing = await readFile(lockPath, "utf8");
- const lock = JSON.parse(existing) as Partial;
-
- let isStale = false;
- if (typeof lock.pid !== "number" || !isProcessAlive(lock.pid)) {
- isStale = true;
- }
- if (!isStale && lock.startedAt) {
- const elapsed = Date.now() - new Date(lock.startedAt).getTime();
- if (Number.isNaN(elapsed) || elapsed > LOCK_TIMEOUT_MS) {
- isStale = true;
- }
- }
-
- if (!isStale) {
- return { acquired: false, lockPath };
- }
-
- await unlink(lockPath).catch(() => {});
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code !== "ENOENT") {
- // Corrupt or unreadable lock — treat as reclaimable.
- await unlink(lockPath).catch(() => {});
- }
- }
-
- const content: LockContent = {
- pid: process.pid,
- owner,
- startedAt: new Date().toISOString(),
- };
-
- try {
- await writeFile(lockPath, JSON.stringify(content), { flag: "wx" });
- return { acquired: true, lockPath };
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === "EEXIST") {
- return { acquired: false, lockPath };
- }
- throw err;
- }
-}
-
-export async function releaseLock(root: string): Promise {
- const lockPath = path.join(root, LOCK_FILE);
- try {
- await unlink(lockPath);
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
- throw err;
- }
- }
+ const release = await lockfile.lock(root, {
+ realpath: false,
+ lockfilePath: lockPath,
+ stale: LOCK_TIMEOUT_MS,
+ update: LOCK_TIMEOUT_MS / 3,
+ retries: {
+ retries: LOCK_TIMEOUT_MS / 250,
+ factor: 1,
+ minTimeout: 250,
+ maxTimeout: 250,
+ randomize: false,
+ },
+ });
+ return { lockPath, owner, release };
}
-/** Run a function while holding the lock; releases even on throw. Returns null if not acquired. */
-export async function withLock(
- root: string,
- owner: string,
- fn: () => Promise,
-): Promise {
+export async function withLock(root: string, owner: string, fn: () => Promise): Promise {
const handle = await acquireLock(root, owner);
- if (!handle.acquired) return null;
try {
return await fn();
} finally {
- await releaseLock(root);
+ await handle.release();
}
}
diff --git a/packages/core/src/recall.test.ts b/packages/core/src/recall.test.ts
index 8ca9f0c..bc9588e 100644
--- a/packages/core/src/recall.test.ts
+++ b/packages/core/src/recall.test.ts
@@ -45,6 +45,8 @@ test("buildMemoryInstructionPrompt is stable rules with no index", () => {
assert.ok(rules.includes("Recall Rules"));
assert.ok(rules.includes("## Sources"));
assert.ok(rules.includes("absolute source trace"));
+ assert.ok(rules.includes("routing metadata only"));
+ assert.ok(rules.includes("must successfully call Read on the exact listed file path"));
assert.ok(!rules.includes(""));
});
diff --git a/packages/core/src/recall.ts b/packages/core/src/recall.ts
index 1ad958f..b497723 100644
--- a/packages/core/src/recall.ts
+++ b/packages/core/src/recall.ts
@@ -82,6 +82,8 @@ You have long-term memory about the current user. The system may provide availab
- MEMORY.md is generated automatically from memory files; do not maintain it manually.
- If multiple available-memory blocks appear in context, use only the latest one; older blocks only reflect earlier context.
- Available memory entries are hints, not complete facts. Read a memory file only when it is clearly relevant to the current user request.
+- Entry names, descriptions, types, and retrieval terms are routing metadata only. Never use them as factual evidence or answer content.
+- Before using any long-term memory information in an answer, you must successfully call Read on the exact listed file path. If no relevant file was read, ignore the entry metadata and answer without it.
- Read a specific memory file only when the user's topic is clearly related to that entry.
- A memory is relevant when it may affect response style, structure, default suggestions, terminology, or collaboration path. Do not ignore it merely because you could answer without reading it.
- For explanation, writing, recommendation, implementation, debugging, review, naming, or planning requests, prefer the 1-2 most relevant style, workflow, preference, context, or ambient memories.
diff --git a/packages/model/LICENSE b/packages/embeddings/LICENSE
similarity index 100%
rename from packages/model/LICENSE
rename to packages/embeddings/LICENSE
diff --git a/packages/model/NOTICE b/packages/embeddings/NOTICE
similarity index 100%
rename from packages/model/NOTICE
rename to packages/embeddings/NOTICE
diff --git a/packages/embeddings/README.md b/packages/embeddings/README.md
new file mode 100644
index 0000000..fc1d52f
--- /dev/null
+++ b/packages/embeddings/README.md
@@ -0,0 +1,15 @@
+# @memflywheel/embeddings
+
+Optional embedding pre-recall for large `MEMORY.md` indexes. MemFlywheel's write-side model path does not use this package; Extraction, Dream, and Skill Evolution run on the host's active model through Pi Agent Core.
+
+```ts
+import { createOpenAIEmbeddingsModel } from "@memflywheel/embeddings";
+
+const embeddingProvider = createOpenAIEmbeddingsModel({
+ endpoint: "https://example.com/v1",
+ apiKey: process.env.MEMFLYWHEEL_EMBEDDING_API_KEY,
+ model: "bge-m3",
+});
+```
+
+Configuration: `MEMFLYWHEEL_EMBEDDING_ENDPOINT`, `MEMFLYWHEEL_EMBEDDING_API_KEY`, `MEMFLYWHEEL_EMBEDDING_MODEL`, and `MEMFLYWHEEL_EMBEDDING_BATCH_SIZE`.
diff --git a/packages/model/THIRD_PARTY_LICENSES b/packages/embeddings/THIRD_PARTY_LICENSES
similarity index 100%
rename from packages/model/THIRD_PARTY_LICENSES
rename to packages/embeddings/THIRD_PARTY_LICENSES
diff --git a/packages/model/package.json b/packages/embeddings/package.json
similarity index 83%
rename from packages/model/package.json
rename to packages/embeddings/package.json
index ba9d62a..f5e8deb 100644
--- a/packages/model/package.json
+++ b/packages/embeddings/package.json
@@ -1,8 +1,8 @@
{
- "name": "@memflywheel/model",
- "version": "0.1.0",
+ "name": "@memflywheel/embeddings",
+ "version": "0.1.1",
"private": true,
- "description": "Provider-neutral tool-calling model protocol for MemFlywheel.",
+ "description": "Optional embedding retrieval provider for MemFlywheel.",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
@@ -24,7 +24,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/iflytek/memflywheel.git",
- "directory": "packages/model"
+ "directory": "packages/embeddings"
},
"bugs": {
"url": "https://github.com/iflytek/memflywheel/issues"
@@ -32,9 +32,9 @@
"homepage": "https://github.com/iflytek/memflywheel#readme",
"keywords": [
"agent-memory",
- "llm",
+ "embeddings",
"memory",
- "tool-calling",
+ "retrieval",
"typescript"
],
"engines": {
diff --git a/packages/embeddings/src/index.test.ts b/packages/embeddings/src/index.test.ts
new file mode 100644
index 0000000..94923f1
--- /dev/null
+++ b/packages/embeddings/src/index.test.ts
@@ -0,0 +1,42 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { createOpenAIEmbeddingsModel } from "./index.js";
+
+test("embedding provider batches requests and preserves vector order", async () => {
+ const bodies: unknown[] = [];
+ const model = createOpenAIEmbeddingsModel({
+ endpoint: "https://embedding.test/v1/",
+ apiKey: "secret",
+ model: "bge-m3",
+ batchSize: 2,
+ fetchImpl: async (_url, init) => {
+ const body = JSON.parse(String(init?.body)) as { input: string[] };
+ bodies.push(body);
+ return new Response(
+ JSON.stringify({ data: body.input.map((text) => ({ embedding: [text.length, 1] })) }),
+ { status: 200 },
+ );
+ },
+ });
+
+ assert.deepEqual(await model.embed({ texts: ["a", "bb", "ccc"] }), {
+ vectors: [
+ [1, 1],
+ [2, 1],
+ [3, 1],
+ ],
+ });
+ assert.deepEqual(bodies, [
+ { model: "bge-m3", input: ["a", "bb"] },
+ { model: "bge-m3", input: ["ccc"] },
+ ]);
+});
+
+test("embedding provider rejects malformed vectors", async () => {
+ const model = createOpenAIEmbeddingsModel({
+ apiKey: "secret",
+ fetchImpl: async () => new Response(JSON.stringify({ data: [{ embedding: [] }] })),
+ });
+ await assert.rejects(() => model.embed({ texts: ["x"] }), /invalid vector/);
+});
diff --git a/packages/embeddings/src/index.ts b/packages/embeddings/src/index.ts
new file mode 100644
index 0000000..7c08c2f
--- /dev/null
+++ b/packages/embeddings/src/index.ts
@@ -0,0 +1,100 @@
+/** Embedding-only infrastructure. Write-side LLM execution belongs to Pi Agent Core. */
+
+export interface EmbeddingProvider {
+ embed(request: { texts: string[]; signal?: AbortSignal }): Promise<{ vectors: number[][] }>;
+}
+
+export interface OpenAIEmbeddingsModelConfig {
+ endpoint?: string;
+ apiKey?: string;
+ model?: string;
+ batchSize?: number;
+ fetchImpl?: typeof fetch;
+}
+
+const DEFAULT_ENDPOINT = "https://api.openai.com/v1";
+const DEFAULT_MODEL = "text-embedding-3-small";
+
+function env(name: string): string | undefined {
+ const value = process.env[name];
+ return value && value.trim() ? value.trim() : undefined;
+}
+
+function apiKey(config: OpenAIEmbeddingsModelConfig): string {
+ const value = config.apiKey ?? env("MEMFLYWHEEL_EMBEDDING_API_KEY") ?? env("OPENAI_API_KEY");
+ if (!value) {
+ throw new Error(
+ "MemFlywheel embeddings model has no API key. Set MEMFLYWHEEL_EMBEDDING_API_KEY or OPENAI_API_KEY.",
+ );
+ }
+ return value;
+}
+
+function endpoint(config: OpenAIEmbeddingsModelConfig): string {
+ return (
+ config.endpoint ??
+ env("MEMFLYWHEEL_EMBEDDING_ENDPOINT") ??
+ env("MEMFLYWHEEL_EMBEDDING_BASE_URL") ??
+ DEFAULT_ENDPOINT
+ ).replace(/\/+$/, "");
+}
+
+function batchSize(config: OpenAIEmbeddingsModelConfig): number | undefined {
+ const value =
+ config.batchSize ?? Number.parseInt(env("MEMFLYWHEEL_EMBEDDING_BATCH_SIZE") ?? "", 10);
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
+}
+
+function parseResponse(json: unknown, expected: number): number[][] {
+ const data = (json as { data?: unknown })?.data;
+ if (!Array.isArray(data) || data.length !== expected) {
+ throw new Error("MemFlywheel embeddings model returned an invalid embedding count.");
+ }
+ return data.map((row, index) => {
+ const vector = (row as { embedding?: unknown })?.embedding;
+ if (
+ !Array.isArray(vector) ||
+ vector.length === 0 ||
+ !vector.every((value) => typeof value === "number" && Number.isFinite(value))
+ ) {
+ throw new Error(`MemFlywheel embeddings model returned an invalid vector at ${index}.`);
+ }
+ return vector;
+ });
+}
+
+export function createOpenAIEmbeddingsModel(
+ config: OpenAIEmbeddingsModelConfig = {},
+): EmbeddingProvider {
+ const baseUrl = endpoint(config);
+ const model = config.model ?? env("MEMFLYWHEEL_EMBEDDING_MODEL") ?? DEFAULT_MODEL;
+ const size = batchSize(config);
+ const request = config.fetchImpl ?? fetch;
+
+ return {
+ async embed({ texts, signal }) {
+ const vectors: number[][] = [];
+ const batch = size ?? Math.max(texts.length, 1);
+ for (let offset = 0; offset < texts.length; offset += batch) {
+ const input = texts.slice(offset, offset + batch);
+ const response = await request(`${baseUrl}/embeddings`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${apiKey(config)}`,
+ },
+ body: JSON.stringify({ model, input }),
+ signal,
+ });
+ if (!response.ok) {
+ const detail = await response.text().catch(() => "");
+ throw new Error(
+ `MemFlywheel embeddings request failed (${response.status}). ${detail}`.trim(),
+ );
+ }
+ vectors.push(...parseResponse(await response.json(), input.length));
+ }
+ return { vectors };
+ },
+ };
+}
diff --git a/packages/model/tsconfig.json b/packages/embeddings/tsconfig.json
similarity index 100%
rename from packages/model/tsconfig.json
rename to packages/embeddings/tsconfig.json
diff --git a/packages/hermes-plugin/README.md b/packages/hermes-plugin/README.md
index 0dc456e..efddf14 100644
--- a/packages/hermes-plugin/README.md
+++ b/packages/hermes-plugin/README.md
@@ -31,6 +31,7 @@ you need to disable skill learning explicitly.
```text
$HERMES_HOME/plugins/memflywheel
+$HERMES_HOME/plugins/memflywheel-guard
```
If `HERMES_HOME` is unset, Hermes' default home is used:
@@ -41,12 +42,13 @@ If `HERMES_HOME` is unset, Hermes' default home is used:
The installer also:
-| Step | Effect |
-| -------------------------- | ------------------------------------------------------------------- |
-| Copy provider files | Installs `__init__.py`, `worker.mjs`, and `plugin.yaml` |
-| Pin adapter import | Writes `install.json` so the worker loads the npm-installed adapter |
-| Disable native memory tool | Adds `memory` to `agent.disabled_toolsets` |
-| Preserve old native memory | Moves `memories/MEMORY.md` to `memories.disabled-by-memflywheel/` |
+| Step | Effect |
+| -------------------------- | ----------------------------------------------------------------------------------------------- |
+| Copy provider files | Installs `__init__.py`, `worker.mjs`, and `plugin.yaml` |
+| Enforce single-writer | Enables `memflywheel-guard`; host reads stay native, host writes to the memory root are blocked |
+| Pin adapter import | Writes `install.json` so the worker loads the npm-installed adapter |
+| Disable native memory tool | Adds `memory` to `agent.disabled_toolsets` |
+| Preserve old native memory | Moves `memories/MEMORY.md` to `memories.disabled-by-memflywheel/` |
## Runtime Files
diff --git a/packages/hermes-plugin/bin/install.mjs b/packages/hermes-plugin/bin/install.mjs
index 8ff7b25..02e7c41 100644
--- a/packages/hermes-plugin/bin/install.mjs
+++ b/packages/hermes-plugin/bin/install.mjs
@@ -8,6 +8,7 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
+import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
@@ -21,12 +22,16 @@ if (process.argv.includes("--help") || process.argv.includes("-h")) {
Installs the MemFlywheel Hermes MemoryProvider plugin into:
$HERMES_HOME/plugins/memflywheel
+Installs and enables the companion host-write guard in:
+ $HERMES_HOME/plugins/memflywheel-guard
+
If HERMES_HOME is unset, the installer uses ~/.hermes.`);
process.exit(0);
}
const hermesHome = process.env.HERMES_HOME || join(homedir(), ".hermes");
const target = join(hermesHome, "plugins", "memflywheel");
+const guardTarget = join(hermesHome, "plugins", "memflywheel-guard");
const adaptersImport = await import.meta.resolve("@iflytekopensource/adapters");
function disableHermesNativeMemoryTool(configPath) {
@@ -79,5 +84,19 @@ writeFileSync(join(target, "install.json"), `${JSON.stringify({ adaptersImport }
mode: 0o600,
});
+mkdirSync(guardTarget, { recursive: true });
+copyFileSync(join(root, "guard", "__init__.py"), join(guardTarget, "__init__.py"));
+copyFileSync(join(root, "guard", "plugin.yaml"), join(guardTarget, "plugin.yaml"));
+
+const enabled = spawnSync(
+ "hermes",
+ ["plugins", "enable", "memflywheel-guard", "--no-allow-tool-override"],
+ { env: { ...process.env, HERMES_HOME: hermesHome }, encoding: "utf8" },
+);
+if (enabled.status !== 0) {
+ throw new Error(enabled.stderr || enabled.stdout || "Failed to enable memflywheel-guard");
+}
+
console.log(`Installed MemFlywheel Hermes provider to ${target}`);
+console.log(`Installed MemFlywheel host write guard to ${guardTarget}`);
console.log("Activate with: hermes config set memory.provider memflywheel");
diff --git a/packages/hermes-plugin/bridge/worker.mjs b/packages/hermes-plugin/bridge/worker.mjs
index 047fbf7..f3cf2df 100644
--- a/packages/hermes-plugin/bridge/worker.mjs
+++ b/packages/hermes-plugin/bridge/worker.mjs
@@ -15,7 +15,8 @@ import { dirname, join } from "node:path";
const adapters = await import(
process.env.MEMFLYWHEEL_ADAPTERS_IMPORT || "@iflytekopensource/adapters"
);
-const { createMemFlywheelHarnessRuntime, normalizeMessages } = adapters;
+const { createAssistantMessageEventStream, createMemFlywheelHarnessRuntime, normalizeMessages } =
+ adapters;
let runtime;
let runtimeKey = "";
@@ -57,6 +58,35 @@ async function complete(request) {
});
}
+const hermesModel = {
+ id: "host-active",
+ name: "Hermes active model",
+ api: "openai-completions",
+ provider: "hermes",
+ baseUrl: "hermes://auxiliary-client",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128_000,
+ maxTokens: 16_384,
+};
+
+function resolveModel() {
+ return {
+ model: hermesModel,
+ streamFn: async (_model, context, options) => {
+ const message = await complete({ context, signal: options?.signal?.aborted === true });
+ const stream = createAssistantMessageEventStream();
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
+ stream.push({ type: "error", reason: message.stopReason, error: message });
+ } else {
+ stream.push({ type: "done", reason: message.stopReason, message });
+ }
+ return stream;
+ },
+ };
+}
+
function ensureRuntime(options = {}) {
const root = options.root || process.env.MEMFLYWHEEL_HOME;
const key = JSON.stringify({
@@ -72,7 +102,7 @@ function ensureRuntime(options = {}) {
: undefined;
runtime = createMemFlywheelHarnessRuntime({
root,
- model: { complete },
+ resolveModel,
cursorStore: root ? createJsonCursorStore(root) : undefined,
refuseSecrets: options.refuseSecrets === true,
learnedSkills,
diff --git a/packages/hermes-plugin/guard/__init__.py b/packages/hermes-plugin/guard/__init__.py
new file mode 100644
index 0000000..fa060da
--- /dev/null
+++ b/packages/hermes-plugin/guard/__init__.py
@@ -0,0 +1,73 @@
+"""Block host-agent writes into the MemFlywheel-owned memory root."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+
+_PATH_WRITE_TOOLS = {"patch", "write_file"}
+_COMMAND_TOOLS = {"terminal", "execute_code", "process"}
+
+
+def _hermes_home() -> Path:
+ try:
+ from hermes_constants import get_hermes_home
+
+ return Path(get_hermes_home())
+ except Exception:
+ return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
+
+
+def _memory_root() -> Path:
+ configured = os.environ.get("MEMFLYWHEEL_HOME", "").strip()
+ config_path = _hermes_home() / "memflywheel.json"
+ if config_path.exists():
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ configured = str(config.get("root") or configured).strip()
+ return Path(configured or (_hermes_home() / "memflywheel")).expanduser().resolve()
+
+
+def _path_is_within(candidate: Any, root: Path) -> bool:
+ if not isinstance(candidate, str) or not candidate.strip():
+ return False
+ resolved = Path(os.path.expandvars(os.path.expanduser(candidate))).resolve()
+ return resolved == root or root in resolved.parents
+
+
+def _command_references_root(args: Any, root: Path) -> bool:
+ if not isinstance(args, dict):
+ return False
+ rendered = json.dumps(args, ensure_ascii=False)
+ home = Path.home().resolve()
+ references = {str(root)}
+ try:
+ relative = root.relative_to(home).as_posix()
+ references.update({f"~/{relative}", f"$HOME/{relative}", f"${{HOME}}/{relative}"})
+ except ValueError:
+ pass
+ return any(reference in rendered for reference in references)
+
+
+def guard_memory_root(
+ tool_name: str = "", args: Any = None, **_: Any
+) -> Optional[Dict[str, str]]:
+ root = _memory_root()
+ if tool_name in _PATH_WRITE_TOOLS and isinstance(args, dict):
+ if _path_is_within(args.get("path") or args.get("filePath"), root):
+ return {
+ "action": "block",
+ "message": "MemFlywheel memory is read-only to the host agent; writes are owned by the memory agent loop.",
+ }
+ if tool_name in _COMMAND_TOOLS and _command_references_root(args, root):
+ return {
+ "action": "block",
+ "message": "MemFlywheel memory is read-only to the host agent; command access to the memory root is blocked.",
+ }
+ return None
+
+
+def register(ctx) -> None:
+ ctx.register_hook("pre_tool_call", guard_memory_root)
diff --git a/packages/hermes-plugin/guard/plugin.yaml b/packages/hermes-plugin/guard/plugin.yaml
new file mode 100644
index 0000000..0ee2186
--- /dev/null
+++ b/packages/hermes-plugin/guard/plugin.yaml
@@ -0,0 +1,6 @@
+name: memflywheel-guard
+version: 0.1.0
+kind: standalone
+description: "Read-only host boundary for the MemFlywheel memory root."
+provides_hooks:
+ - pre_tool_call
diff --git a/packages/hermes-plugin/package.json b/packages/hermes-plugin/package.json
index d27a36a..a5bf593 100644
--- a/packages/hermes-plugin/package.json
+++ b/packages/hermes-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@iflytekopensource/hermes",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Hermes MemoryProvider plugin for MemFlywheel.",
"license": "Apache-2.0",
"type": "module",
@@ -13,16 +13,19 @@
"THIRD_PARTY_LICENSES",
"bin/**/*.mjs",
"bridge/**/*.mjs",
+ "guard/**/*.py",
+ "guard/plugin.yaml",
"provider/**/*.py",
"plugin.yaml",
"README.md"
],
"scripts": {
- "build": "node --check bridge/worker.mjs && node --check bin/install.mjs && python3 - <<'PY'\nfrom pathlib import Path\ncompile(Path('provider/__init__.py').read_text(), 'provider/__init__.py', 'exec')\nPY",
+ "build": "node --check bridge/worker.mjs && node --check bin/install.mjs && python3 - <<'PY'\nfrom pathlib import Path\nfor file in ('provider/__init__.py', 'guard/__init__.py'):\n compile(Path(file).read_text(), file, 'exec')\nPY",
"install:local": "node bin/install.mjs",
"test": "PYTHONDONTWRITEBYTECODE=1 python3 tests/provider_contract_test.py"
},
"dependencies": {
+ "@earendil-works/pi-ai": "^0.82.1",
"@iflytekopensource/adapters": "workspace:*"
},
"engines": {
diff --git a/packages/hermes-plugin/provider/__init__.py b/packages/hermes-plugin/provider/__init__.py
index 588f89b..e6afdd6 100644
--- a/packages/hermes-plugin/provider/__init__.py
+++ b/packages/hermes-plugin/provider/__init__.py
@@ -122,12 +122,32 @@ def _parse_tool_args(raw: Any) -> Any:
return raw
+def _text_content(content: Any) -> str:
+ if isinstance(content, str):
+ return content
+ if not isinstance(content, list):
+ return ""
+ return "\n".join(
+ part.get("text", "")
+ for part in content
+ if isinstance(part, dict) and part.get("type") == "text"
+ )
+
+
def _to_openai_message(message: dict) -> dict:
role = message.get("role")
- wire = {"role": role, "content": message.get("content")}
- if message.get("toolCallId"):
- wire["tool_call_id"] = message["toolCallId"]
- calls = message.get("toolCalls") or []
+ if role == "toolResult":
+ return {
+ "role": "tool",
+ "tool_call_id": message.get("toolCallId"),
+ "content": _text_content(message.get("content")),
+ }
+ calls = [
+ part
+ for part in message.get("content", [])
+ if isinstance(part, dict) and part.get("type") == "toolCall"
+ ] if role == "assistant" else []
+ wire = {"role": role, "content": _text_content(message.get("content")) or None}
if calls:
wire["tool_calls"] = [
{
@@ -135,7 +155,7 @@ def _to_openai_message(message: dict) -> dict:
"type": "function",
"function": {
"name": call["name"],
- "arguments": json.dumps(call.get("input") or {}, ensure_ascii=False),
+ "arguments": json.dumps(call.get("arguments") or {}, ensure_ascii=False),
},
}
for call in calls
@@ -149,31 +169,49 @@ def _to_openai_tool(tool: dict) -> dict:
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
- "parameters": tool["inputSchema"],
+ "parameters": tool["parameters"],
},
}
-def _canonical_response(response: Any) -> dict:
+def _pi_response(response: Any) -> dict:
choice = _first_choice(response)
raw_message = _get(choice, "message", {})
- calls = []
+ content = []
+ text = _get(raw_message, "content")
+ if text:
+ content.append({"type": "text", "text": text})
for raw_call in _get(raw_message, "tool_calls", []) or []:
fn = _get(raw_call, "function", {})
- calls.append(
+ content.append(
{
+ "type": "toolCall",
"id": _get(raw_call, "id"),
"name": _get(fn, "name"),
- "input": _parse_tool_args(_get(fn, "arguments")),
+ "arguments": _parse_tool_args(_get(fn, "arguments")),
}
)
- message = {
+ usage = _get(response, "usage", {}) or {}
+ input_tokens = _get(usage, "prompt_tokens", 0) or 0
+ output_tokens = _get(usage, "completion_tokens", 0) or 0
+ has_tools = any(part.get("type") == "toolCall" for part in content)
+ return {
"role": "assistant",
- "content": _get(raw_message, "content"),
+ "content": content,
+ "api": "openai-completions",
+ "provider": "hermes",
+ "model": _get(response, "model", "host-active"),
+ "usage": {
+ "input": input_tokens,
+ "output": output_tokens,
+ "cacheRead": 0,
+ "cacheWrite": 0,
+ "totalTokens": input_tokens + output_tokens,
+ "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0},
+ },
+ "stopReason": "toolUse" if has_tools else "stop",
+ "timestamp": int(time.time() * 1000),
}
- if calls:
- message["toolCalls"] = calls
- return {"message": message, "finishReason": _get(choice, "finish_reason")}
class _MemFlywheelBridge:
@@ -211,7 +249,9 @@ def request(self, command: str, payload: dict, timeout: int) -> Any:
raise TimeoutError(f"MemFlywheel worker command timed out: {command}")
line = proc.stdout.readline()
if line == "":
- raise RuntimeError("MemFlywheel worker exited")
+ detail = proc.stderr.read().strip() if proc.stderr else ""
+ suffix = f": {detail}" if detail else ""
+ raise RuntimeError(f"MemFlywheel worker exited{suffix}")
message = json.loads(line)
msg_type = message.get("type")
if msg_type == "model_request":
@@ -235,7 +275,7 @@ def _ensure_process(self) -> subprocess.Popen:
["node", str(_worker_path())],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
env=env,
@@ -374,13 +414,17 @@ def shutdown(self) -> None:
def complete_model(self, request: dict) -> dict:
from agent.auxiliary_client import call_llm
+ context = request.get("context", {})
+ messages = [_to_openai_message(m) for m in context.get("messages", [])]
+ if context.get("systemPrompt"):
+ messages.insert(0, {"role": "system", "content": context["systemPrompt"]})
response = call_llm(
task=None,
- messages=[_to_openai_message(m) for m in request.get("messages", [])],
- tools=[_to_openai_tool(t) for t in request.get("tools", [])],
+ messages=messages,
+ tools=[_to_openai_tool(t) for t in context.get("tools", [])],
timeout=self._config.get("timeout", 180),
)
- return _canonical_response(response)
+ return _pi_response(response)
def _request(self, command: str, payload: dict) -> Any:
payload = {
diff --git a/packages/hermes-plugin/tests/provider_contract_test.py b/packages/hermes-plugin/tests/provider_contract_test.py
index fdb2026..a2a4d07 100644
--- a/packages/hermes-plugin/tests/provider_contract_test.py
+++ b/packages/hermes-plugin/tests/provider_contract_test.py
@@ -31,6 +31,43 @@ class MemoryProvider:
assert spec and spec.loader
spec.loader.exec_module(module)
+guard_path = Path(__file__).resolve().parents[1] / "guard" / "__init__.py"
+guard_spec = importlib.util.spec_from_file_location("memflywheel_guard", guard_path)
+guard_module = importlib.util.module_from_spec(guard_spec)
+assert guard_spec and guard_spec.loader
+guard_spec.loader.exec_module(guard_module)
+
+
+class HookCollector:
+ def __init__(self):
+ self.hooks = {}
+
+ def register_hook(self, name, callback):
+ self.hooks[name] = callback
+
+
+hook_collector = HookCollector()
+guard_module.register(hook_collector)
+assert "pre_tool_call" in hook_collector.hooks
+
+with tempfile.TemporaryDirectory() as tmp:
+ previous_home = os.environ.get("HERMES_HOME")
+ os.environ["HERMES_HOME"] = tmp
+ try:
+ root = str((Path(tmp) / "memflywheel").resolve())
+ guard = hook_collector.hooks["pre_tool_call"]
+ assert guard("read_file", {"path": f"{root}/preference/test.md"}) is None
+ assert guard("patch", {"path": f"{root}/preference/test.md"})["action"] == "block"
+ assert guard("write_file", {"path": str(Path(tmp) / "outside.md")}) is None
+ assert guard(
+ "terminal", {"command": f"sed -i '' s/a/b/ {root}/preference/test.md"}
+ )["action"] == "block"
+ finally:
+ if previous_home is None:
+ os.environ.pop("HERMES_HOME", None)
+ else:
+ os.environ["HERMES_HOME"] = previous_home
+
provider = module.MemFlywheelMemoryProvider()
assert provider.name == "memflywheel"
assert provider.is_available()
@@ -54,7 +91,6 @@ def __init__(self):
def register_memory_provider(self, provider):
self.provider = provider
-
collector = Collector()
module.register(collector)
assert collector.provider.name == "memflywheel"
@@ -81,7 +117,38 @@ def register_memory_provider(self, provider):
assert (installed / "worker.mjs").exists()
assert (installed / "plugin.yaml").exists()
assert (installed / "install.json").exists()
- assert "adaptersImport" in json.loads((installed / "install.json").read_text(encoding="utf-8"))
+ guard = Path(tmp) / "plugins" / "memflywheel-guard"
+ assert (guard / "__init__.py").exists()
+ assert (guard / "plugin.yaml").exists()
+ install_metadata = json.loads((installed / "install.json").read_text(encoding="utf-8"))
+ assert "adaptersImport" in install_metadata
+ installed_worker = subprocess.run(
+ ["node", str(installed / "worker.mjs")],
+ cwd=installed,
+ env={**env, "MEMFLYWHEEL_ADAPTERS_IMPORT": install_metadata["adaptersImport"]},
+ input=json.dumps(
+ {
+ "type": "command",
+ "id": "installed-init",
+ "command": "initialize",
+ "payload": {
+ "sessionId": "installed-contract",
+ "hermesHome": tmp,
+ "root": str(Path(tmp) / "memflywheel"),
+ "refuseSecrets": True,
+ "learnedSkills": True,
+ },
+ }
+ ) + "\n",
+ text=True,
+ capture_output=True,
+ timeout=20,
+ check=True,
+ )
+ installed_response = json.loads(installed_worker.stdout.strip())
+ assert installed_response["type"] == "command_response"
+ assert installed_response["id"] == "installed-init"
+ assert installed_response.get("error") is None
disabled_memory = Path(tmp) / "memories.disabled-by-memflywheel" / "MEMORY.md"
assert disabled_memory.read_text(encoding="utf-8") == "native hermes memory\n"
assert not native_memory.exists()
diff --git a/packages/model/README.md b/packages/model/README.md
deleted file mode 100644
index e65ba8d..0000000
--- a/packages/model/README.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# @memflywheel/model
-
-Provider-neutral tool-calling model protocol for MemFlywheel.
-
-This package is the only model shape consumed by `@memflywheel/sdk` subagent
-loops. Host runtimes and providers map their native model APIs into
-`CanonicalModelCompletion`; memory, dream, and skill-learning code never consumes
-provider wire fields directly.
-
-## Contract
-
-```ts
-import type { CanonicalModelCompletion } from "@memflywheel/model";
-
-const model: CanonicalModelCompletion = {
- complete: async (req) => {
- // req.messages: canonical system/user/assistant/tool messages
- // req.tools: canonical tool definitions with JSON schemas
- return {
- message: {
- role: "assistant",
- toolCalls: [
- {
- id: "call-1",
- name: "write",
- input: { filePath: "preference/example.md", content: "..." },
- },
- ],
- },
- finishReason: "tool-calls",
- };
- },
-};
-```
-
-Tool call inputs are structured values, not JSON strings. If a provider uses
-JSON-string arguments, its mapper must parse them before returning canonical
-tool calls and fail on malformed input.
-
-## OpenAI-Compatible Mapper
-
-```ts
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-
-const model = createOpenAIChatCompletionsModel({
- endpoint: "https://api.openai.com/v1",
- model: "gpt-4o-mini",
- apiKey: process.env.MEMFLYWHEEL_LLM_API_KEY,
-});
-```
-
-Environment variables:
-
-| Variable | Meaning |
-| ---------------------------- | ------------------------------------------ |
-| `MEMFLYWHEEL_LLM_ENDPOINT` | Base URL without `/chat/completions` |
-| `MEMFLYWHEEL_LLM_API_KEY` | API key, with `OPENAI_API_KEY` as fallback |
-| `MEMFLYWHEEL_LLM_MODEL` | Model id |
-| `MEMFLYWHEEL_LLM_MAX_TOKENS` | Output token cap |
-
-## OpenAI-Compatible Embeddings Mapper
-
-```ts
-import { createOpenAIEmbeddingsModel } from "@memflywheel/model";
-
-const embeddings = createOpenAIEmbeddingsModel({
- endpoint: "https://api.cloudflare.com/client/v4/accounts//ai/v1",
- model: "@cf/baai/bge-m3",
- apiKey: process.env.MEMFLYWHEEL_EMBEDDING_API_KEY,
-});
-```
-
-This mapper is for optional `MEMORY.md` index-layer retrieval. It does not embed
-memory bodies and it does not make MemFlywheel a vector database.
-
-| Variable | Meaning |
-| ------------------------------------------------------------------- | ------------------------------------------ |
-| `MEMFLYWHEEL_EMBEDDING_ENDPOINT` / `MEMFLYWHEEL_EMBEDDING_BASE_URL` | Base URL without `/embeddings` |
-| `MEMFLYWHEEL_EMBEDDING_API_KEY` | API key, with `OPENAI_API_KEY` as fallback |
-| `MEMFLYWHEEL_EMBEDDING_MODEL` | Embedding model id |
-| `MEMFLYWHEEL_EMBEDDING_BATCH_SIZE` | Maximum texts per embeddings request |
-
-OpenAI-compatible HTTP is just one mapper. Native host adapters should prefer
-the host-owned model/auth/lifecycle channel.
diff --git a/packages/model/src/index.test.ts b/packages/model/src/index.test.ts
deleted file mode 100644
index 6fffd43..0000000
--- a/packages/model/src/index.test.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import { test } from "node:test";
-import assert from "node:assert/strict";
-
-import {
- createOpenAIChatCompletionsModel,
- createOpenAIEmbeddingsModel,
- type CanonicalModelCompletion,
- type CanonicalToolDefinition,
-} from "./index.js";
-
-const toolSchema = {
- type: "object",
- properties: {
- filePath: { type: "string" },
- content: { type: "string" },
- },
- required: ["filePath", "content"],
- additionalProperties: false,
-} as const;
-
-const tools: CanonicalToolDefinition[] = [
- {
- name: "write",
- description: "Write memory file",
- inputSchema: toolSchema,
- },
-];
-
-test("OpenAI mapper serializes canonical messages and parses tool calls into structured inputs", async () => {
- let captured: Record | undefined;
- const fetchImpl: typeof fetch = async (_url, init) => {
- captured = JSON.parse(String(init?.body));
- return new Response(
- JSON.stringify({
- choices: [
- {
- finish_reason: "tool_calls",
- message: {
- role: "assistant",
- content: null,
- tool_calls: [
- {
- id: "call_1",
- type: "function",
- function: {
- name: "write",
- arguments: '{"filePath":"preference/tea.md","content":"Green tea"}',
- },
- },
- ],
- },
- },
- ],
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- };
-
- const model: CanonicalModelCompletion = createOpenAIChatCompletionsModel({
- apiKey: "sk-test",
- endpoint: "https://model.example/v1",
- model: "test-model",
- fetchImpl,
- });
-
- const response = await model.complete({
- messages: [
- {
- role: "assistant",
- content: null,
- toolCalls: [{ id: "c1", name: "glob", input: { pattern: "**/*.md" } }],
- },
- { role: "tool", toolCallId: "c1", content: "(none)" },
- ],
- tools,
- });
-
- const body = captured as {
- tools: Array<{ function: { name: string; parameters: unknown } }>;
- messages: Array>;
- };
- assert.equal(body.tools[0]?.function.name, "write");
- assert.deepEqual(body.tools[0]?.function.parameters, toolSchema);
- assert.equal(
- (body.messages[0]?.tool_calls as Array<{ function: { arguments: string } }>)[0]?.function
- .arguments,
- '{"pattern":"**/*.md"}',
- );
- assert.equal(body.messages[1]?.tool_call_id, "c1");
-
- assert.equal(response.finishReason, "tool_calls");
- assert.deepEqual(response.message.toolCalls, [
- {
- id: "call_1",
- name: "write",
- input: { filePath: "preference/tea.md", content: "Green tea" },
- },
- ]);
-});
-
-test("OpenAI mapper leaves provider sampling and token limit unset by default", async () => {
- let captured: Record | undefined;
- const fetchImpl: typeof fetch = async (_url, init) => {
- captured = JSON.parse(String(init?.body));
- return new Response(
- JSON.stringify({
- choices: [{ finish_reason: "stop", message: { role: "assistant", content: "ok" } }],
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- };
-
- const model = createOpenAIChatCompletionsModel({
- apiKey: "sk-test",
- endpoint: "https://model.example/v1",
- model: "test-model",
- fetchImpl,
- });
-
- await model.complete({ messages: [{ role: "user", content: "hello" }], tools });
-
- assert.ok(captured);
- assert.equal(Object.hasOwn(captured, "max_tokens"), false);
- assert.equal(Object.hasOwn(captured, "temperature"), false);
-});
-
-test("OpenAI mapper serializes provider sampling and token limit only when configured", async () => {
- let captured: Record | undefined;
- const fetchImpl: typeof fetch = async (_url, init) => {
- captured = JSON.parse(String(init?.body));
- return new Response(
- JSON.stringify({
- choices: [{ finish_reason: "stop", message: { role: "assistant", content: "ok" } }],
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- };
-
- const model = createOpenAIChatCompletionsModel({
- apiKey: "sk-test",
- endpoint: "https://model.example/v1",
- model: "test-model",
- maxTokens: 2048,
- temperature: 0.2,
- fetchImpl,
- });
-
- await model.complete({ messages: [{ role: "user", content: "hello" }], tools });
-
- assert.equal(captured?.max_tokens, 2048);
- assert.equal(captured?.temperature, 0.2);
-});
-
-test("OpenAI mapper fails on malformed provider tool arguments instead of repairing them", async () => {
- const fetchImpl: typeof fetch = async () =>
- new Response(
- JSON.stringify({
- choices: [
- {
- finish_reason: "tool_calls",
- message: {
- role: "assistant",
- tool_calls: [
- {
- id: "call_bad",
- type: "function",
- function: { name: "write", arguments: "{not-json" },
- },
- ],
- },
- },
- ],
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
-
- const model = createOpenAIChatCompletionsModel({
- apiKey: "sk-test",
- fetchImpl,
- });
-
- await assert.rejects(
- () => model.complete({ messages: [{ role: "user", content: "x" }], tools }),
- /invalid JSON tool arguments/,
- );
-});
-
-test("OpenAI embeddings mapper calls /embeddings and returns canonical vectors", async () => {
- let capturedUrl = "";
- let capturedAuth = "";
- let captured: Record | undefined;
- const fetchImpl: typeof fetch = async (url, init) => {
- capturedUrl = String(url);
- capturedAuth = String((init?.headers as Record).authorization);
- captured = JSON.parse(String(init?.body));
- return new Response(
- JSON.stringify({
- data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }],
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- };
-
- const embeddings = createOpenAIEmbeddingsModel({
- apiKey: "sk-test",
- endpoint: "https://embedding.example/v1",
- model: "@cf/baai/bge-m3",
- fetchImpl,
- });
-
- const result = await embeddings.embed({ texts: ["release", "tea"] });
-
- assert.equal(capturedUrl, "https://embedding.example/v1/embeddings");
- assert.equal(capturedAuth, "Bearer sk-test");
- assert.deepEqual(captured, { model: "@cf/baai/bge-m3", input: ["release", "tea"] });
- assert.deepEqual(result.vectors, [
- [0.1, 0.2, 0.3],
- [0.4, 0.5, 0.6],
- ]);
-});
-
-test("OpenAI embeddings mapper batches requests when batchSize is set", async () => {
- const batches: unknown[] = [];
- const fetchImpl: typeof fetch = async (_url, init) => {
- const body = JSON.parse(String(init?.body)) as { input: string[] };
- batches.push(body.input);
- return new Response(
- JSON.stringify({
- data: body.input.map((text) => ({ embedding: [text.length] })),
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- );
- };
-
- const embeddings = createOpenAIEmbeddingsModel({
- apiKey: "sk-test",
- batchSize: 2,
- fetchImpl,
- });
-
- const result = await embeddings.embed({ texts: ["a", "bb", "ccc"] });
-
- assert.deepEqual(batches, [["a", "bb"], ["ccc"]]);
- assert.deepEqual(result.vectors, [[1], [2], [3]]);
-});
-
-test("OpenAI embeddings mapper fails on invalid vector counts", async () => {
- const fetchImpl: typeof fetch = async () =>
- new Response(JSON.stringify({ data: [{ embedding: [1, 2] }] }), {
- status: 200,
- headers: { "content-type": "application/json" },
- });
-
- const embeddings = createOpenAIEmbeddingsModel({
- apiKey: "sk-test",
- fetchImpl,
- });
-
- await assert.rejects(() => embeddings.embed({ texts: ["a", "b"] }), /invalid embedding count/);
-});
diff --git a/packages/model/src/index.ts b/packages/model/src/index.ts
deleted file mode 100644
index aec220f..0000000
--- a/packages/model/src/index.ts
+++ /dev/null
@@ -1,347 +0,0 @@
-/**
- * Provider-neutral tool-calling model protocol for MemFlywheel.
- *
- * This package is the only model contract the SDK consumes. Provider wire shapes
- * such as OpenAI Chat Completions live behind mappers here; the memory, skill,
- * and dream loops never depend on provider-specific fields like `tool_calls` or
- * JSON-string arguments.
- */
-
-export interface JsonSchemaObject {
- type: "object";
- properties: Record;
- required: readonly string[];
- additionalProperties: false;
-}
-
-export type CanonicalModelRole = "system" | "user" | "assistant" | "tool";
-
-export interface CanonicalToolCall {
- id: string;
- name: string;
- input: unknown;
-}
-
-export interface CanonicalModelMessage {
- role: CanonicalModelRole;
- content?: string | null;
- toolCalls?: CanonicalToolCall[];
- toolCallId?: string;
-}
-
-export interface CanonicalToolDefinition {
- name: string;
- description: string;
- inputSchema: JsonSchemaObject;
- strict?: boolean;
-}
-
-export interface CanonicalModelRequest {
- messages: CanonicalModelMessage[];
- tools: CanonicalToolDefinition[];
- signal?: AbortSignal;
-}
-
-export interface CanonicalModelResponse {
- message: CanonicalModelMessage;
- finishReason?: string;
-}
-
-export interface CanonicalModelCompletion {
- complete(req: CanonicalModelRequest): Promise;
-}
-
-export type CanonicalModelComplete = CanonicalModelCompletion["complete"];
-
-export interface OpenAIChatCompletionsModelConfig {
- /** Base URL without `/chat/completions`. */
- endpoint?: string;
- /** API key. Falls back to MEMFLYWHEEL_LLM_API_KEY / OPENAI_API_KEY. */
- apiKey?: string;
- /** Model id. */
- model?: string;
- /** Output token cap. */
- maxTokens?: number;
- /** Sampling temperature. */
- temperature?: number;
- /** Injectable fetch for tests and host-owned transports. */
- fetchImpl?: typeof fetch;
-}
-
-export interface CanonicalEmbeddingProvider {
- embed(req: { texts: string[]; signal?: AbortSignal }): Promise<{ vectors: number[][] }>;
-}
-
-export interface OpenAIEmbeddingsModelConfig {
- /** Base URL without `/embeddings`. */
- endpoint?: string;
- /** API key. Falls back to MEMFLYWHEEL_EMBEDDING_API_KEY / OPENAI_API_KEY. */
- apiKey?: string;
- /** Embedding model id. */
- model?: string;
- /** Maximum input texts per `/embeddings` request. */
- batchSize?: number;
- /** Injectable fetch for tests and host-owned transports. */
- fetchImpl?: typeof fetch;
-}
-
-const DEFAULT_ENDPOINT = "https://api.openai.com/v1";
-const DEFAULT_MODEL = "gpt-4o-mini";
-const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
-
-function env(name: string): string | undefined {
- const value = process.env[name];
- return value && value.trim() ? value.trim() : undefined;
-}
-
-function resolveApiKey(config: OpenAIChatCompletionsModelConfig): string {
- const key = config.apiKey ?? env("MEMFLYWHEEL_LLM_API_KEY") ?? env("OPENAI_API_KEY");
- if (!key) {
- throw new Error(
- "MemFlywheel OpenAI chat model: no API key. Set MEMFLYWHEEL_LLM_API_KEY or OPENAI_API_KEY.",
- );
- }
- return key;
-}
-
-function resolveEndpoint(config: OpenAIChatCompletionsModelConfig): string {
- const base = config.endpoint ?? env("MEMFLYWHEEL_LLM_ENDPOINT") ?? DEFAULT_ENDPOINT;
- return base.replace(/\/+$/, "");
-}
-
-function resolveModel(config: OpenAIChatCompletionsModelConfig): string {
- return config.model ?? env("MEMFLYWHEEL_LLM_MODEL") ?? DEFAULT_MODEL;
-}
-
-function resolveEmbeddingApiKey(config: OpenAIEmbeddingsModelConfig): string {
- const key = config.apiKey ?? env("MEMFLYWHEEL_EMBEDDING_API_KEY") ?? env("OPENAI_API_KEY");
- if (!key) {
- throw new Error(
- "MemFlywheel OpenAI embeddings model: no API key. Set MEMFLYWHEEL_EMBEDDING_API_KEY or OPENAI_API_KEY.",
- );
- }
- return key;
-}
-
-function resolveEmbeddingEndpoint(config: OpenAIEmbeddingsModelConfig): string {
- const base =
- config.endpoint ??
- env("MEMFLYWHEEL_EMBEDDING_ENDPOINT") ??
- env("MEMFLYWHEEL_EMBEDDING_BASE_URL") ??
- DEFAULT_ENDPOINT;
- return base.replace(/\/+$/, "");
-}
-
-function resolveEmbeddingModel(config: OpenAIEmbeddingsModelConfig): string {
- return config.model ?? env("MEMFLYWHEEL_EMBEDDING_MODEL") ?? DEFAULT_EMBEDDING_MODEL;
-}
-
-function resolveEmbeddingBatchSize(config: OpenAIEmbeddingsModelConfig): number | undefined {
- if (typeof config.batchSize === "number")
- return config.batchSize > 0 ? config.batchSize : undefined;
- const fromEnv = env("MEMFLYWHEEL_EMBEDDING_BATCH_SIZE");
- const parsed = fromEnv ? Number.parseInt(fromEnv, 10) : NaN;
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
-}
-
-function resolveMaxTokens(config: OpenAIChatCompletionsModelConfig): number | undefined {
- if (typeof config.maxTokens === "number") return config.maxTokens;
- const fromEnv = env("MEMFLYWHEEL_LLM_MAX_TOKENS");
- const parsed = fromEnv ? Number.parseInt(fromEnv, 10) : NaN;
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
-}
-
-function toWireTool(tool: CanonicalToolDefinition): Record {
- return {
- type: "function",
- function: {
- name: tool.name,
- description: tool.description,
- parameters: tool.inputSchema,
- ...(tool.strict === undefined ? {} : { strict: tool.strict }),
- },
- };
-}
-
-function stringifyToolInput(input: unknown, toolName: string): string {
- try {
- return JSON.stringify(input ?? {});
- } catch (error) {
- throw new Error(
- `MemFlywheel OpenAI chat model: canonical tool input for ${toolName} is not JSON-serializable.`,
- { cause: error },
- );
- }
-}
-
-function toWireMessage(message: CanonicalModelMessage): Record {
- const wire: Record = {
- role: message.role,
- content: message.content ?? null,
- };
- if (message.toolCalls && message.toolCalls.length > 0) {
- wire.tool_calls = message.toolCalls.map((call) => ({
- id: call.id,
- type: "function",
- function: {
- name: call.name,
- arguments: stringifyToolInput(call.input, call.name),
- },
- }));
- }
- if (message.toolCallId) wire.tool_call_id = message.toolCallId;
- return wire;
-}
-
-function parseToolInput(raw: unknown, id: string): unknown {
- if (raw === undefined || raw === null || raw === "") return {};
- if (typeof raw !== "string") {
- throw new Error(
- `MemFlywheel OpenAI chat model: tool call ${id} arguments must be a JSON string.`,
- );
- }
- try {
- return JSON.parse(raw);
- } catch (error) {
- throw new Error(`MemFlywheel OpenAI chat model: invalid JSON tool arguments for ${id}.`, {
- cause: error,
- });
- }
-}
-
-function parseResponse(json: unknown): CanonicalModelResponse {
- const choice = (json as { choices?: Array<{ message?: unknown; finish_reason?: unknown }> })
- ?.choices?.[0];
- if (!choice) {
- throw new Error("MemFlywheel OpenAI chat model: response has no choices.");
- }
- const rawMessage = (choice.message ?? {}) as {
- content?: unknown;
- tool_calls?: unknown;
- };
-
- const toolCalls: CanonicalToolCall[] = [];
- if (Array.isArray(rawMessage.tool_calls)) {
- for (const rawCall of rawMessage.tool_calls) {
- const call = rawCall as {
- id?: unknown;
- function?: { name?: unknown; arguments?: unknown };
- };
- if (typeof call.id !== "string" || call.id.trim() === "") {
- throw new Error("MemFlywheel OpenAI chat model: provider tool call missing id.");
- }
- if (typeof call.function?.name !== "string" || call.function.name.trim() === "") {
- throw new Error(`MemFlywheel OpenAI chat model: tool call ${call.id} missing name.`);
- }
- toolCalls.push({
- id: call.id,
- name: call.function.name,
- input: parseToolInput(call.function.arguments, call.id),
- });
- }
- }
-
- const message: CanonicalModelMessage = {
- role: "assistant",
- content: typeof rawMessage.content === "string" ? rawMessage.content : null,
- };
- if (toolCalls.length > 0) message.toolCalls = toolCalls;
- return {
- message,
- finishReason: typeof choice.finish_reason === "string" ? choice.finish_reason : undefined,
- };
-}
-
-export function createOpenAIChatCompletionsModel(
- config: OpenAIChatCompletionsModelConfig = {},
-): CanonicalModelCompletion {
- const endpoint = resolveEndpoint(config);
- const model = resolveModel(config);
- const maxTokens = resolveMaxTokens(config);
- const temperature = typeof config.temperature === "number" ? config.temperature : undefined;
- const doFetch = config.fetchImpl ?? fetch;
-
- return {
- async complete(req: CanonicalModelRequest): Promise {
- const body = JSON.stringify({
- model,
- ...(maxTokens === undefined ? {} : { max_tokens: maxTokens }),
- ...(temperature === undefined ? {} : { temperature }),
- messages: req.messages.map(toWireMessage),
- tools: req.tools.map(toWireTool),
- tool_choice: "auto",
- });
- const response = await doFetch(`${endpoint}/chat/completions`, {
- method: "POST",
- headers: {
- "content-type": "application/json",
- authorization: `Bearer ${resolveApiKey(config)}`,
- },
- body,
- signal: req.signal,
- });
- if (!response.ok) {
- const detail = await response.text().catch(() => "");
- throw new Error(
- `MemFlywheel OpenAI chat model: request failed (${response.status}). ${detail}`.trim(),
- );
- }
- return parseResponse(await response.json());
- },
- };
-}
-
-function parseEmbeddingsResponse(json: unknown, expectedCount: number): { vectors: number[][] } {
- const data = (json as { data?: unknown })?.data;
- if (!Array.isArray(data) || data.length !== expectedCount) {
- throw new Error("MemFlywheel OpenAI embeddings model: invalid embedding count.");
- }
- const vectors = data.map((row, index) => {
- const embedding = (row as { embedding?: unknown })?.embedding;
- if (
- !Array.isArray(embedding) ||
- embedding.length === 0 ||
- !embedding.every((value) => typeof value === "number" && Number.isFinite(value))
- ) {
- throw new Error(`MemFlywheel OpenAI embeddings model: invalid embedding vector at ${index}.`);
- }
- return embedding;
- });
- return { vectors };
-}
-
-export function createOpenAIEmbeddingsModel(
- config: OpenAIEmbeddingsModelConfig = {},
-): CanonicalEmbeddingProvider {
- const endpoint = resolveEmbeddingEndpoint(config);
- const model = resolveEmbeddingModel(config);
- const batchSize = resolveEmbeddingBatchSize(config);
- const doFetch = config.fetchImpl ?? fetch;
-
- return {
- async embed(req): Promise<{ vectors: number[][] }> {
- const vectors: number[][] = [];
- const size = batchSize ?? req.texts.length;
- for (let offset = 0; offset < req.texts.length; offset += size) {
- const texts = req.texts.slice(offset, offset + size);
- const response = await doFetch(`${endpoint}/embeddings`, {
- method: "POST",
- headers: {
- "content-type": "application/json",
- authorization: `Bearer ${resolveEmbeddingApiKey(config)}`,
- },
- body: JSON.stringify({ model, input: texts }),
- signal: req.signal,
- });
- if (!response.ok) {
- const detail = await response.text().catch(() => "");
- throw new Error(
- `MemFlywheel OpenAI embeddings model: request failed (${response.status}). ${detail}`.trim(),
- );
- }
- vectors.push(...parseEmbeddingsResponse(await response.json(), texts.length).vectors);
- }
- return { vectors };
- },
- };
-}
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index 734f4e1..e5d83b7 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -1,266 +1,20 @@
# @memflywheel/sdk
-Host lifecycle integration layer for [MemFlywheel](https://github.com/iflytek/memflywheel#readme). The thin
-orchestration seam between host runtimes such as Pi, Hermes, OpenClaw, OpenCode, and `@memflywheel/core`.
-
-The SDK owns:
-
-- a single per-root `StorageContext` + audit logger,
-- the per-session extraction cursor store,
-- the **two pluggable LLM injection points** (`agent`, `dreamRunner`),
-- the default subagent loops that consume `@memflywheel/model`'s canonical model protocol,
-- optional learned-skill prompt routing,
-- the host lifecycle hooks that decide _when_ core runs.
-
-The SDK does not execute learned skills. It can expose routing metadata and
-capture tool-call facts in the session transcript, while the host owns skill
-loading, execution policy, permissions, and tool calls.
-
-The SDK orchestrates after-turn extraction; the actual LLM work is externalized
-to an `ExtractionAgentRunner` — a tool-calling subagent that writes the memory
-files itself through ordinary file tools. That agent can be the built-in default
-(see below) or host-supplied. Zero runtime dependencies.
-
-## Default extraction + dream subagents (batteries included)
-
-You do not have to write the prompts or wire the tool loop. The core ships a
-curated default extraction system prompt and a default dream consolidation system
-prompt, plus ordinary file tools (`read` / `write` / `edit` / `bash` /
-`glob` / `grep`) as **pure values** — it never
-makes a network call. The SDK assembles them into running tool-calling subagents
-over one provider-neutral model channel. There is ONE model channel — `model` —
-and it drives both subagents:
-
-```ts
-import {
- createMemFlywheel,
- createExtractionAgentRunner,
- createDreamAgentRunner,
-} from "@memflywheel/sdk";
-import { createOpenAIChatCompletionsModel } from "@memflywheel/model";
-
-// The OpenAI-compatible mapper lives in @memflywheel/model. Hosts can provide
-// their own CanonicalModelCompletion instead.
-const model = createOpenAIChatCompletionsModel();
-const scribe = createMemFlywheel({
- agent: createExtractionAgentRunner({ model }),
- dreamRunner: createDreamAgentRunner({ model }),
-});
-```
-
-Pass your own `CanonicalModelCompletion` to route through a host's existing LLM
-channel, or pass a fully custom `ExtractionAgentRunner` / `DreamAgentRunner` to
-replace the defaults.
-
-## Quick start
-
-```ts
-import { createMemFlywheel, type ExtractionAgentRunner } from "@memflywheel/sdk";
-
-// Host wraps its own tool-calling LLM here. Core never calls a model; the
-// subagent writes memories itself via the supplied tools.
-const agent: ExtractionAgentRunner = async ({ tools, toolCtx, messages, manifest, root }) => {
- // …drive your tool-calling model with `tools` (read / write / edit / bash /
- // glob / grep), given `messages` + `manifest`; each call
- // writes a file via tools[i].handler(args, toolCtx)…
- return { changed: ["preference/favorite-fruit.md"] };
-};
-
-const scribe = createMemFlywheel({ agent }); // root resolves from MEMFLYWHEEL_HOME / OS data dir
-
-await scribe.onSessionStart("session-1");
-
-// At prompt assembly time — two recall segments:
-const { systemPrompt, preludePrompt } = await scribe.onPromptBuild({
- sessionId: "session-1",
- query: "记住:我喜欢草莓",
-});
-// systemPrompt → STABLE memory rules (cache-friendly prefix)
-// preludePrompt → DYNAMIC index cues, wrapped in
-
-// After each turn, hand the SDK the turn's messages; it runs extraction:
-await scribe.onTurnEnd("session-1", [
- { role: "user", text: "记住:我喜欢草莓" },
- { role: "assistant", text: "好的" },
-]);
-
-await scribe.onSessionEnd("session-1");
-```
-
-## Prompt recall
-
-`onPromptBuild()` returns `{ systemPrompt, preludePrompt, enabled, skillPreludePrompt? }`:
-
-| Segment | Content | Cadence |
-| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
-| `systemPrompt` | Stable memory **rules** — constant text, identical every turn | inject once / cache as a stable prefix |
-| `preludePrompt` | Full/truncated `MEMORY.md` index, or an optional index-layer hybrid retrieval subset, plus optional learned-skill routes wrapped in `` | inject every turn |
-| `skillPreludePrompt` | Optional learned-skill route index | inject when `skillRecall` is configured |
-
-Retrieval is only over `MEMORY.md` index lines when `memoryIndexRetrieval` is configured
-and the index is large enough to need it. Memory bodies are not embedded or searched; the
-main model still self-selects whether to use the host's normal Read/file tool for any body.
-
-When `skillRecall` is configured, the SDK also injects learned-skill routing
-metadata. The host still owns actual skill loading and execution.
-
-## The two pluggable LLM injection points
-
-Both are optional. Core/SDK own timing, locking, atomic writes, index sync, and
-the cursor; the host owns only the LLM call.
-
-```ts
-// 1) Extraction — a tool-calling subagent that WRITES the memory files itself.
-type ExtractionAgentRunner = (input: {
- toolCtx: FileToolContext; // bound inside the held write lock
- tools: FileTool[]; // read / write / edit / bash / glob / grep
- messages: { role: "user" | "assistant"; text: string }[];
- manifest: string; // formatManifest(existing entries)
- root: string;
-}) => Promise<{ changed: string[] }>; // the relative paths it touched
-
-// 2) Dream — the SAME kind of tool-calling subagent, seeded for consolidation. It
-// reads full bodies and merges / compresses / retires memories via the tools.
-type DreamAgentRunner = (input: {
- root: string;
- toolCtx: FileToolContext; // bound inside the held write lock
- tools: FileTool[]; // same ordinary file tools as extraction
- health: HealthFinding[];
- typeReview: TypeReviewItem[];
- manifest: string;
- index: string;
- coordination?: { reason: string; memoryAction: string; topics: string[]; targetSkill?: string };
-}) => Promise<{ changed: string[] }>; // the relative paths it touched
-```
-
-Each subagent decides add vs. update on its own — it can call `glob` / `grep`
-to locate, `read` to load a full body, then `write` for a new typed Markdown
-file, `edit` to refine a same-topic file, or `bash` to move retired files under
-`.archive/` before writing the replacement.
-
-## Lifecycle hooks
-
-| Hook | When the host calls it | What the scribe does |
-| ------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
-| `onSessionStart(id)` | session opens | ensure memory dir, register session state |
-| `onTurnStart(id)` | new turn begins | register session state |
-| `onPromptBuild({sessionId?, query?})` | assembling the prompt | return the two recall segments |
-| `onTurnEnd(id, msgs)` | turn finished | append turn -> run extraction; with an opt-in `learningLoop`, host/adapters can also run skill evolution and forced dream coordination |
-| `onSessionEnd(id)` | session closes | drop session state |
-| `onAgentEnd(id)` | auxiliary/agent run ends | final extraction sweep over not-yet-processed messages |
-| `onIdle(gate?)` | idle / scheduled | gate-check then `runDreamSession`: deterministic pre-pass, then the consolidation subagent via `dreamRunner` |
-
-### Explicit host operations
-
-- `context()` — the default index prelude + stable rules.
-- `save(options)` — explicit validated typed Markdown write under the lock, then
- index sync.
-- `runDream(coordination?)` — force a dream pass regardless of the gate.
-
-There is deliberately **no public read/search tool** — MemFlywheel has no lexical retrieval,
-and recall reads go through the host filesystem surface.
-These operations are for host integrations that need direct governance hooks;
-they are not a standalone runtime surface.
-
-## Skill learning loop
-
-The SDK exposes primitives and opt-in hooks without becoming a full agent
-framework. Host/adapters can assemble the loop:
+The SDK owns MemFlywheel's lifecycle and one write-side agent implementation:
```text
-prompt-build
- -> memory recall
- -> learned skill routes
-
-turn-end
- -> extraction
- -> skillEvolution({ lastExtraction, session })
- -> dream({ memoryAction: "compress-memory", topics }) when derived or custom coordination requests memory compression
+Extraction ─┐
+Dream ──────┼─> runMemoryAgent() ─> @earendil-works/pi-agent-core Agent
+Skill ──────┘ |
+ └─ host-resolved pi-ai Model + StreamFn
```
-```ts
-createMemFlywheel({
- agent,
- dreamRunner,
- skillRecall: async ({ sessionId }) => ({
- entries: [
- {
- name: "memflywheel-learned-release-review",
- displayName: "Release Review",
- description: "Review release readiness with a repeatable checklist.",
- relativePath: "memflywheel-learned-release-review/SKILL.md",
- triggerHints: ["release prep"],
- },
- ],
- }),
- learningLoop: {
- source: "local",
- skillLearningEnabled: true,
- skillEvolution: async ({ lastExtraction, session }) => {
- // Custom SDK hook; adapters can also assemble this with learnedSkills.
- return {
- coordination: {
- decision: "update",
- targetSkill: "memflywheel-learned-release-review",
- mergedSkills: [],
- why: "Release prep became a reusable procedure.",
- memoryAction: "compress-memory",
- memoryTopics: ["release prep"],
- supportingFiles: ["memflywheel-learned-release-review/SKILL.md"],
- },
- changedSkills: ["memflywheel-learned-release-review"],
- changedFiles: ["memflywheel-learned-release-review/SKILL.md"],
- };
- },
- },
-});
-```
-
-If `toolCalls` and `turnsSinceLastSkillEvolution` are omitted, the SDK counts
-captured `ExtractionMessage.toolCalls` and tracks the last skill-evolution turn
-per session. Skill evolution runs only after extraction returns `Completed`;
-skipped or failed extraction returns `extraction-not-completed`.
-
-## After-turn extraction semantics
-
-`onTurnEnd` / `onAgentEnd` delegate to core's `runExtractionSession`, which owns
-the full lifecycle:
-
-1. acquire the per-root write lock (else the run is enqueued → `Queued`)
-2. relocate stray root-level files into their typed dirs
-3. before-scan → manifest
-4. select the cursor window over cleaned messages (`` blocks and
- prelude text stripped from user turns; assistant turns kept verbatim)
-5. build the bound tools + context (sharing the held lock) and `await agent(...)`,
- which drives the subagent; the subagent writes via the tools (each handler:
- path-safety check → atomic write → audit append → index resync)
-6. relocate again → after-scan → sync `MEMORY.md`
-7. advance the cursor **only on success**; stamp `.last-extraction`
-8. release the lock, drain the pending queue
-
-The agent throwing yields `Failed` and advances nothing (a later turn retries the
-same window).
-
-## Configuration
+Hosts provide a `ResolvePiAgentModel` that returns the active `Model`, `streamFn`, auth resolver, session id, and thinking level. The SDK does not parse provider wire formats, replay reduced assistant messages, read write-side LLM environment variables, or implement its own retry loop.
```ts
-createMemFlywheel({
- root?: string, // override; else MEMFLYWHEEL_HOME / OS data dir
- enabled?: boolean, // false ⇒ all hooks are no-ops
- agent?: ExtractionAgentRunner, // injection point #1 (absent ⇒ extraction skipped)
- dreamRunner?: DreamAgentRunner, // injection point #2 (absent ⇒ deterministic pre-pass only)
- refuseSecrets?: boolean, // optional hard-secret gate (default off; redaction always on)
- audit?: AuditLogger, // default: file-backed at /.audit.log
- cursorStore?: CursorStore, // default: in-memory
- skillRecall?: SkillRecallProvider,
- skillPreludeBuilder?: SkillPreludeBuilder,
- learningLoop?: MemFlywheelLearningLoopConfig,
-});
-```
-
-## Build & test
+import { createExtractionAgentRunner } from "@memflywheel/sdk";
-```bash
-pnpm --filter @memflywheel/sdk build
-pnpm --filter @memflywheel/sdk test # node:test, tsc-compiled
+const agent = createExtractionAgentRunner({ resolveModel });
```
+
+Tool execution is sequential because memory and skill tools mutate files. A provider error, abort, or `maxSteps` breach fails the pass; callers must not advance cursors or finalize staged skills after a partial run.
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 9d71cb9..9777e3c 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@memflywheel/sdk",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"description": "Host lifecycle SDK for MemFlywheel extraction, recall, and consolidation.",
"license": "Apache-2.0",
@@ -46,8 +46,10 @@
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
"dependencies": {
+ "@earendil-works/pi-agent-core": "^0.82.1",
+ "@earendil-works/pi-ai": "^0.82.1",
"@memflywheel/core": "workspace:*",
- "@memflywheel/model": "workspace:*"
+ "@memflywheel/embeddings": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
diff --git a/packages/sdk/src/agent-runner.test.ts b/packages/sdk/src/agent-runner.test.ts
new file mode 100644
index 0000000..9f3b4df
--- /dev/null
+++ b/packages/sdk/src/agent-runner.test.ts
@@ -0,0 +1,27 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { registerSessionResourceCleanup } from "@earendil-works/pi-ai";
+
+import { runMemoryAgent } from "./agent-runner.js";
+import { scriptedBinding, stopTurn } from "./agent-test-support.test.js";
+
+test("runMemoryAgent releases pi-ai resources for its isolated session", async () => {
+ const scripted = scriptedBinding([stopTurn]);
+ const cleaned: Array = [];
+ const unregister = registerSessionResourceCleanup((sessionId) => cleaned.push(sessionId));
+ try {
+ await runMemoryAgent({
+ resolveModel: async () => ({
+ ...(await scripted.resolveModel()),
+ sessionId: "memflywheel:test-session",
+ }),
+ tools: [],
+ systemPrompt: "test",
+ userMessage: "test",
+ });
+ assert.deepEqual(cleaned, ["memflywheel:test-session"]);
+ } finally {
+ unregister();
+ }
+});
diff --git a/packages/sdk/src/agent-runner.ts b/packages/sdk/src/agent-runner.ts
new file mode 100644
index 0000000..f58ba37
--- /dev/null
+++ b/packages/sdk/src/agent-runner.ts
@@ -0,0 +1,144 @@
+import { Agent, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
+import type {
+ Api,
+ AssistantMessage,
+ Model,
+ SimpleStreamOptions,
+ Transport,
+} from "@earendil-works/pi-ai";
+import { cleanupSessionResources } from "@earendil-works/pi-ai";
+import type { TSchema } from "typebox";
+
+export interface PiAgentModelBinding {
+ model: Model;
+ streamFn: StreamFn;
+ getApiKey?: (provider: string) => Promise | string | undefined;
+ sessionId?: string;
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
+ transport?: Transport;
+ /** Host-resolved request context forwarded unchanged on every provider turn. */
+ request?: SimpleStreamOptions & Record;
+}
+
+export type ResolvePiAgentModel = () => PiAgentModelBinding | Promise;
+
+export interface MemoryAgentToolResult {
+ ok: boolean;
+ text: string;
+ changed?: string[];
+}
+
+export interface MemoryAgentTool {
+ name: string;
+ description: string;
+ inputSchema: object;
+ execute(args: unknown, signal?: AbortSignal): Promise;
+}
+
+export interface MemoryAgentResult {
+ steps: number;
+ toolCalls: Array<{ name: string; ok: boolean }>;
+ changed: string[];
+ finalContent: string | null;
+}
+
+export interface RunMemoryAgentOptions {
+ resolveModel: ResolvePiAgentModel;
+ tools: MemoryAgentTool[];
+ systemPrompt: string;
+ userMessage: string;
+ maxSteps?: number;
+ signal?: AbortSignal;
+}
+
+const DEFAULT_MAX_STEPS = 12;
+
+function maxSteps(value: number | undefined): number {
+ const resolved = value ?? DEFAULT_MAX_STEPS;
+ if (!Number.isSafeInteger(resolved) || resolved < 1) {
+ throw new Error("maxSteps must be a positive integer");
+ }
+ return resolved;
+}
+
+function assistantText(message: AssistantMessage): string | null {
+ const text = message.content
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
+ .join("\n")
+ .trim();
+ return text || null;
+}
+
+/** Run every MemFlywheel write-side task on Pi's official Agent implementation. */
+export async function runMemoryAgent(options: RunMemoryAgentOptions): Promise {
+ const binding = await options.resolveModel();
+ const limit = maxSteps(options.maxSteps);
+ const toolCalls: MemoryAgentResult["toolCalls"] = [];
+ const changed = new Set();
+ let steps = 0;
+ let reachedLimit = false;
+ let finalContent: string | null = null;
+
+ const tools: AgentTool[] = options.tools.map((tool) => ({
+ name: tool.name,
+ label: tool.name,
+ description: tool.description,
+ parameters: tool.inputSchema as TSchema,
+ executionMode: "sequential",
+ async execute(_toolCallId, args, signal) {
+ const result = await tool.execute(args, signal);
+ toolCalls.push({ name: tool.name, ok: result.ok });
+ for (const path of result.changed ?? []) changed.add(path);
+ if (!result.ok) throw new Error(result.text);
+ return {
+ content: [{ type: "text", text: result.text }],
+ details: { changed: result.changed ?? [] },
+ };
+ },
+ }));
+
+ const agent = new Agent({
+ initialState: {
+ systemPrompt: options.systemPrompt,
+ model: binding.model,
+ thinkingLevel: binding.thinkingLevel ?? "off",
+ tools,
+ messages: [],
+ },
+ streamFn: (model, context, streamOptions) =>
+ binding.streamFn(model, context, { ...binding.request, ...streamOptions }),
+ getApiKey: binding.getApiKey,
+ sessionId: binding.sessionId,
+ transport: binding.transport,
+ toolExecution: "sequential",
+ });
+
+ const unsubscribe = agent.subscribe((event) => {
+ if (event.type !== "turn_end") return;
+ steps += 1;
+ if (event.message.role === "assistant") finalContent = assistantText(event.message);
+ const requestedTools =
+ event.message.role === "assistant" &&
+ event.message.content.some((part) => part.type === "toolCall");
+ if (requestedTools && steps >= limit) {
+ reachedLimit = true;
+ agent.abort();
+ }
+ });
+ const abort = () => agent.abort();
+ options.signal?.addEventListener("abort", abort, { once: true });
+
+ try {
+ await agent.prompt(options.userMessage);
+ } finally {
+ options.signal?.removeEventListener("abort", abort);
+ unsubscribe();
+ if (binding.sessionId) cleanupSessionResources(binding.sessionId);
+ }
+
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("memory agent aborted");
+ if (reachedLimit) throw new Error(`memory agent exceeded maxSteps (${limit})`);
+ if (agent.state.errorMessage) throw new Error(agent.state.errorMessage);
+
+ return { steps, toolCalls, changed: [...changed], finalContent };
+}
diff --git a/packages/sdk/src/agent-test-support.test.ts b/packages/sdk/src/agent-test-support.test.ts
new file mode 100644
index 0000000..91310d1
--- /dev/null
+++ b/packages/sdk/src/agent-test-support.test.ts
@@ -0,0 +1,100 @@
+import {
+ createAssistantMessageEventStream,
+ type AssistantMessage,
+ type Context,
+ type Model,
+} from "@earendil-works/pi-ai";
+
+import type { ResolvePiAgentModel } from "./agent-runner.js";
+
+const model: Model<"openai-completions"> = {
+ id: "test-model",
+ name: "Test model",
+ api: "openai-completions",
+ provider: "test",
+ baseUrl: "http://test.invalid",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 16_384,
+ maxTokens: 4_096,
+};
+
+const usage = {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+export interface ScriptedToolCall {
+ id: string;
+ name: string;
+ arguments: Record;
+}
+
+export interface ScriptedTurn {
+ text?: string;
+ toolCalls?: ScriptedToolCall[];
+}
+
+export function toolTurn(name: string, args: Record, id = "c1"): ScriptedTurn {
+ return { toolCalls: [{ id, name, arguments: args }] };
+}
+
+export const stopTurn: ScriptedTurn = { text: "done" };
+
+export function scriptedBinding(
+ turns: ScriptedTurn[],
+ options: { apiKey?: string } = {},
+): {
+ resolveModel: ResolvePiAgentModel;
+ contexts: () => Context[];
+ requestApiKeys: () => (string | undefined)[];
+} {
+ const seen: Context[] = [];
+ const seenApiKeys: (string | undefined)[] = [];
+ let index = 0;
+ return {
+ contexts: () => seen,
+ requestApiKeys: () => seenApiKeys,
+ resolveModel: () => ({
+ model,
+ ...(options.apiKey ? { getApiKey: () => options.apiKey } : {}),
+ streamFn: (_model, context, streamOptions) => {
+ seenApiKeys.push(streamOptions?.apiKey);
+ seen.push({
+ systemPrompt: context.systemPrompt,
+ messages: structuredClone(context.messages),
+ tools: context.tools ? [...context.tools] : undefined,
+ });
+ const turn = turns[index++];
+ if (!turn) throw new Error("unexpected test model call");
+ const content: AssistantMessage["content"] = [];
+ if (turn.text) content.push({ type: "text", text: turn.text });
+ for (const call of turn.toolCalls ?? []) {
+ content.push({ type: "toolCall", ...call });
+ }
+ const message: AssistantMessage = {
+ role: "assistant",
+ content,
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage,
+ stopReason: turn.toolCalls?.length ? "toolUse" : "stop",
+ timestamp: Date.now(),
+ };
+ const stream = createAssistantMessageEventStream();
+ stream.push({
+ type: "done",
+ reason: turn.toolCalls?.length ? "toolUse" : "stop",
+ message,
+ });
+ return stream;
+ },
+ }),
+ };
+}
diff --git a/packages/sdk/src/assembly.test.ts b/packages/sdk/src/assembly.test.ts
index eab37ab..a5b0132 100644
--- a/packages/sdk/src/assembly.test.ts
+++ b/packages/sdk/src/assembly.test.ts
@@ -1,434 +1,163 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtemp, rm, readFile } from "node:fs/promises";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
- createMemFlywheel,
createDreamAgentRunner,
- runExtractionAgent,
createExtractionAgentRunner,
- MAX_EXTRACTION_STEPS,
- createFileTools,
- ExtractionResult,
+ createMemFlywheel,
+ runExtractionAgent,
} from "./index.js";
import {
createAuditLogger,
+ createFileTools,
createMemoryFileToolContext,
+ ExtractionResult,
serializeMemoryFile,
- type MemoryType,
type StorageContext,
} from "@memflywheel/core";
-import type {
- CanonicalModelCompletion,
- CanonicalModelRequest,
- CanonicalModelResponse,
-} from "@memflywheel/model";
+import { scriptedBinding, stopTurn, toolTurn } from "./agent-test-support.test.js";
async function tempRoot(): Promise {
return mkdtemp(path.join(tmpdir(), "memflywheel-sdk-assembly-"));
}
-/**
- * A scripted canonical model: returns each queued response in turn, recording the
- * requests it received (so multi-turn re-feed can be asserted).
- */
-function scriptedModel(responses: CanonicalModelResponse[]): {
- model: CanonicalModelCompletion;
- requests: () => CanonicalModelRequest[];
-} {
- const seen: CanonicalModelRequest[] = [];
- let i = 0;
- const model: CanonicalModelCompletion = {
- complete: async (req) => {
- seen.push(req);
- const r = responses[Math.min(i, responses.length - 1)];
- i += 1;
- return r;
- },
- };
- return { model, requests: () => seen };
-}
-
-function toolCallResponse(name: string, args: unknown, id = "c1"): CanonicalModelResponse {
+function writeArgs(filePath: string, body: string) {
return {
- message: {
- role: "assistant",
- content: null,
- toolCalls: [{ id, name, input: args }],
- },
- finishReason: "tool-calls",
+ filePath,
+ content: serializeMemoryFile({
+ type: filePath.split("/")[0] as "preference" | "ambient",
+ name: path.basename(filePath, ".md"),
+ body,
+ }),
};
}
-const STOP_RESPONSE: CanonicalModelResponse = {
- message: { role: "assistant", content: "done" },
- finishReason: "stop",
-};
-
-function slug(name: string): string {
- return `${
- name
- .toLowerCase()
- .replace(/[^a-z0-9一-龥]+/g, "-")
- .replace(/^-+|-+$/g, "") || "memory"
- }.md`;
-}
-
-function writeMemoryArgs(input: {
- type: MemoryType;
- name: string;
- description?: string;
- body: string;
- filename?: string;
-}) {
- return {
- filePath: `${input.type}/${input.filename ?? slug(input.name)}`,
- content: serializeMemoryFile(input),
- };
-}
-
-// ---- runExtractionAgent: the tool-calling loop drives core's file tools. ----
-
-test("runExtractionAgent: subagent calls write then stops; file is written", async () => {
+test("Pi Agent Core preserves the native assistant/tool transcript across turns", async () => {
const root = await tempRoot();
try {
const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- const { model, requests } = scriptedModel([
- toolCallResponse(
- "write",
- writeMemoryArgs({
- type: "preference",
- name: "Preferred drink",
- description: "go-to beverage",
- body: "The user prefers green tea over coffee.",
- }),
- ),
- STOP_RESPONSE,
+ const { resolveModel, contexts } = scriptedBinding([
+ toolTurn("write", writeArgs("preference/preferred-drink.md", "The user prefers green tea.")),
+ stopTurn,
]);
-
const result = await runExtractionAgent({
- model,
+ resolveModel,
tools: createFileTools(),
toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "I always drink green tea, not coffee." }],
+ messages: [{ role: "user", text: "I prefer green tea." }],
manifest: "(none)",
});
assert.equal(result.steps, 2);
- assert.equal(result.stoppedReason, "no-tool-calls");
assert.deepEqual(result.toolCalls, [{ name: "write", ok: true }]);
- assert.equal(result.changed.length, 1);
-
- // The subagent wrote the file directly via the tool handler.
- const file = await readFile(path.join(root, "preference", "preferred-drink.md"), "utf8");
- assert.match(file, /green tea/);
-
- // Multi-turn re-feed: the second request carries the assistant tool-calls turn
- // plus a role:"tool" result message.
- const reqs = requests();
- assert.equal(reqs.length, 2);
- const second = reqs[1].messages;
- const toolMsg = second.find((m) => m.role === "tool");
- assert.ok(toolMsg, "a role:tool result was fed back");
- assert.equal(toolMsg?.toolCallId, "c1");
- assert.match(String(toolMsg?.content), /preference\/preferred-drink\.md/);
- // And the assistant tool-calls turn was preserved in history.
- assert.ok(second.some((m) => m.role === "assistant" && (m.toolCalls?.length ?? 0) > 0));
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
-
-test("runExtractionAgent: subagent calls glob first, then write", async () => {
- const root = await tempRoot();
- try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- const { model } = scriptedModel([
- toolCallResponse("glob", { pattern: "**/*.md" }, "l1"),
- toolCallResponse(
- "write",
- writeMemoryArgs({ type: "identity", name: "Address as", body: "Call the user Dr. Mara." }),
- "s1",
- ),
- STOP_RESPONSE,
- ]);
-
- const result = await runExtractionAgent({
- model,
- tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "Address me as Dr. Mara." }],
- manifest: "(none)",
- });
-
- assert.equal(result.steps, 3);
- assert.deepEqual(
- result.toolCalls.map((c) => c.name),
- ["glob", "write"],
+ assert.match(
+ await readFile(path.join(root, "preference/preferred-drink.md"), "utf8"),
+ /green tea/,
);
- const file = await readFile(path.join(root, "identity", "address-as.md"), "utf8");
- assert.match(file, /Dr\. Mara/);
+ assert.equal(contexts()[1]?.messages.at(-2)?.role, "assistant");
+ assert.equal(contexts()[1]?.messages.at(-1)?.role, "toolResult");
+ const assistant = contexts()[1]?.messages.at(-2);
+ assert.equal(assistant?.role === "assistant" ? assistant.usage.totalTokens : undefined, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-test("runExtractionAgent: maxSteps caps the loop when the model keeps calling tools", async () => {
+test("Pi Agent Core forwards the host credential on every tool-loop request", async () => {
const root = await tempRoot();
try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- // Always returns a tool call ⇒ loop only stops on maxSteps.
- const model: CanonicalModelCompletion = {
- complete: async () => toolCallResponse("glob", { pattern: "**/*.md" }, `c${Math.random()}`),
- };
-
- const result = await runExtractionAgent({
- model,
- tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "x" }],
- manifest: "(none)",
- maxSteps: 3,
- });
- assert.equal(result.steps, 3);
- assert.equal(result.stoppedReason, "max-steps");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
+ const { resolveModel, requestApiKeys } = scriptedBinding(
+ [toolTurn("glob", { pattern: "**/*.md" }), stopTurn],
+ { apiKey: "host-owned" },
+ );
-test("runExtractionAgent: hard cap clamps a runaway loop to MAX_EXTRACTION_STEPS (20)", async () => {
- const root = await tempRoot();
- try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- let n = 0;
- const model: CanonicalModelCompletion = {
- complete: async () => toolCallResponse("glob", { pattern: "**/*.md" }, `c${(n += 1)}`),
- };
- const result = await runExtractionAgent({
- model,
+ await runExtractionAgent({
+ resolveModel,
tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "x" }],
+ toolCtx: createMemoryFileToolContext({
+ ctx: { root, audit: createAuditLogger(root) },
+ }),
+ messages: [{ role: "user", text: "remember the stable preference" }],
manifest: "(none)",
- maxSteps: 100, // requested above the hard cap
});
- assert.equal(MAX_EXTRACTION_STEPS, 20);
- assert.equal(result.steps, 20); // clamped — never runs away
- assert.equal(result.stoppedReason, "max-steps");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
-test("runExtractionAgent: retries a transient error (429) then succeeds", async () => {
- const root = await tempRoot();
- try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- let calls = 0;
- const model: CanonicalModelCompletion = {
- complete: async () => {
- calls += 1;
- if (calls === 1)
- throw new Error("MemFlywheel model completion: request failed (429). rate limited");
- return STOP_RESPONSE;
- },
- };
- const result = await runExtractionAgent({
- model,
- tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "x" }],
- manifest: "(none)",
- });
- assert.equal(calls, 2); // first threw (retryable), retried and succeeded
- assert.equal(result.stoppedReason, "no-tool-calls");
+ assert.deepEqual(requestApiKeys(), ["host-owned", "host-owned"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-test("runExtractionAgent: a non-retryable error (401) propagates without retry", async () => {
+test("maxSteps fails the run instead of accepting a partial write-side pass", async () => {
const root = await tempRoot();
try {
const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- let calls = 0;
- const model: CanonicalModelCompletion = {
- complete: async () => {
- calls += 1;
- throw new Error("MemFlywheel model completion: request failed (401). invalid api key");
- },
- };
+ const { resolveModel } = scriptedBinding([
+ toolTurn("glob", { pattern: "**/*.md" }, "c1"),
+ toolTurn("glob", { pattern: "**/*.md" }, "c2"),
+ ]);
await assert.rejects(
runExtractionAgent({
- model,
+ resolveModel,
tools: createFileTools(),
toolCtx: createMemoryFileToolContext({ ctx }),
messages: [{ role: "user", text: "x" }],
manifest: "(none)",
+ maxSteps: 2,
}),
- /401/,
+ /exceeded maxSteps \(2\)/,
);
- assert.equal(calls, 1); // auth error is not retried
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
-
-test("runExtractionAgent: an aborted signal stops the loop gracefully", async () => {
- const root = await tempRoot();
- try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- const ac = new AbortController();
- ac.abort();
- let called = false;
- const model: CanonicalModelCompletion = {
- complete: async () => {
- called = true;
- return STOP_RESPONSE;
- },
- };
- const result = await runExtractionAgent({
- model,
- tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "x" }],
- manifest: "(none)",
- signal: ac.signal,
- });
- assert.equal(result.stoppedReason, "aborted");
- assert.equal(result.steps, 0);
- assert.equal(called, false); // no model call after abort
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
-
-test("runExtractionAgent: no tool call on the first turn ⇒ immediate decline, no writes", async () => {
- const root = await tempRoot();
- try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- const { model } = scriptedModel([STOP_RESPONSE]);
- const result = await runExtractionAgent({
- model,
- tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "what time is it?" }],
- manifest: "(none)",
- });
- assert.equal(result.steps, 1);
- assert.equal(result.stoppedReason, "no-tool-calls");
- assert.deepEqual(result.changed, []);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-test("runExtractionAgent: unknown canonical tool calls are reported, not thrown", async () => {
+test("extraction runner writes memory and advances only after a complete Agent run", async () => {
const root = await tempRoot();
try {
- const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- const { model } = scriptedModel([
- toolCallResponse("missing_file_tool", { pattern: "**/*.md" }, "c1"),
- STOP_RESPONSE,
+ const { resolveModel } = scriptedBinding([
+ toolTurn("write", writeArgs("preference/fruit.md", "The user likes strawberries.")),
+ stopTurn,
]);
- const result = await runExtractionAgent({
- model,
- tools: createFileTools(),
- toolCtx: createMemoryFileToolContext({ ctx }),
- messages: [{ role: "user", text: "x" }],
- manifest: "(none)",
+ const scribe = createMemFlywheel({
+ root,
+ agent: createExtractionAgentRunner({ resolveModel }),
});
- assert.deepEqual(result.toolCalls, [{ name: "missing_file_tool", ok: false }]);
- assert.deepEqual(result.changed, []);
- } finally {
- await rm(root, { recursive: true, force: true });
- }
-});
-
-// ---- createExtractionAgentRunner + scribe: end-to-end turn-end write. ----
-
-test("createExtractionAgentRunner: turn-end writes a memory via the agent loop", async () => {
- const root = await tempRoot();
- try {
- const { model } = scriptedModel([
- toolCallResponse(
- "write",
- writeMemoryArgs({
- type: "preference",
- name: "fruit",
- body: "The user likes strawberries.",
- }),
- ),
- STOP_RESPONSE,
- ]);
- const scribe = createMemFlywheel({ root, agent: createExtractionAgentRunner({ model }) });
await scribe.onSessionStart("s1");
+ const result = await scribe.onTurnEnd("s1", [{ role: "user", text: "I love strawberries." }]);
- const turn = await scribe.onTurnEnd("s1", [
- { role: "user", text: "I love strawberries, remember that." },
- ]);
- assert.equal(turn.skipped, false);
- assert.equal(turn.result, ExtractionResult.Completed);
-
- const file = await readFile(path.join(root, "preference", "fruit.md"), "utf8");
- assert.match(file, /likes strawberries/);
- const index = await readFile(path.join(root, "MEMORY.md"), "utf8");
- assert.match(index, /fruit/);
+ assert.equal(result.result, ExtractionResult.Completed);
+ assert.match(await readFile(path.join(root, "preference/fruit.md"), "utf8"), /strawberries/);
+ assert.match(await readFile(path.join(root, "MEMORY.md"), "utf8"), /fruit/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-// ---- createDreamAgentRunner: dream is the same tool-calling loop, seeded for consolidation. ----
-
-test("createDreamAgentRunner: drives the dream subagent over ordinary file tools with the consolidation prompt + packets", async () => {
+test("dream uses the same Pi Agent Core runner", async () => {
const root = await tempRoot();
try {
const ctx: StorageContext = { root, audit: createAuditLogger(root) };
- const { model, requests } = scriptedModel([
- toolCallResponse(
- "write",
- writeMemoryArgs({
- type: "ambient",
- name: "Team",
- description: "team roles",
- body: "Mara leads backend.",
- }),
- "d1",
- ),
- STOP_RESPONSE,
+ const { resolveModel, contexts } = scriptedBinding([
+ toolTurn("write", writeArgs("ambient/team.md", "Mara leads backend.")),
+ stopTurn,
]);
-
- const runner = createDreamAgentRunner({ model });
+ const runner = createDreamAgentRunner({ resolveModel });
const result = await runner({
root,
toolCtx: createMemoryFileToolContext({ ctx }),
tools: createFileTools(),
- health: [
- { severity: "warn", code: "duplicate-content", paths: ["ambient/a.md"], message: "dup" },
- ],
- typeReview: [
- { path: "ambient/a.md", type: "ambient", name: "a", description: "d", excerpt: "x" },
- ],
- manifest: "ambient/a.md",
+ health: [],
+ typeReview: [],
+ manifest: "(none)",
index: "# MEMORY",
- coordination: { reason: "idle", memoryAction: "consolidate", topics: ["team"] },
});
- // The subagent wrote directly via the tool handler.
- assert.ok(result.changed.includes("ambient/team.md"));
- const file = await readFile(path.join(root, "ambient", "team.md"), "utf8");
- assert.match(file, /Mara leads backend/);
-
- // The loop was seeded with the dream consolidation system prompt + packets.
- const reqs = requests();
- assert.match(String(reqs[0].messages[0].content), /consolidation engine/);
- assert.match(String(reqs[0].messages[1].content), /Health findings/);
- assert.match(String(reqs[0].messages[1].content), /Type review/);
- assert.match(String(reqs[0].messages[1].content), /memoryAction: consolidate/);
+ assert.deepEqual(result.changed, ["ambient/team.md"]);
+ assert.match(contexts()[0]?.systemPrompt ?? "", /consolidation engine/);
+ assert.match(JSON.stringify(contexts()[0]?.messages[0]?.content), /Health findings/);
} finally {
await rm(root, { recursive: true, force: true });
}
diff --git a/packages/sdk/src/dream-agent.ts b/packages/sdk/src/dream-agent.ts
index c442b23..1249b77 100644
--- a/packages/sdk/src/dream-agent.ts
+++ b/packages/sdk/src/dream-agent.ts
@@ -2,7 +2,7 @@
* The dream-consolidation subagent: the shared tool-calling loop, seeded for
* dream.
*
- * Seeds {@link runToolAgent} with the dream system prompt + a user message
+ * Seeds {@link runMemoryAgent} with the dream system prompt + a user message
* rendering the structural packets (index / manifest / health / type-review),
* then drives the model to consolidate by calling ordinary file tools directly —
* reading full bodies before merging or compressing. `createDreamAgentRunner`
@@ -21,13 +21,16 @@ import {
buildDreamAgentUserMessage,
} from "@memflywheel/core";
-import type { CanonicalModelCompletion } from "@memflywheel/model";
-import { type ToolAgentResult, runToolAgent } from "./tool-agent.js";
+import {
+ type MemoryAgentResult,
+ type ResolvePiAgentModel,
+ runMemoryAgent,
+} from "./agent-runner.js";
/** Options for {@link runDreamAgent}. */
export interface RunDreamAgentOptions {
- /** The host-owned canonical model channel. */
- model: CanonicalModelCompletion;
+ /** Resolve the host's active pi-ai model binding for this run. */
+ resolveModel: ResolvePiAgentModel;
/** The file tools (from core.createFileTools()), advertised + executed. */
tools: FileTool[];
/** The context the handlers write through (shares the held lock). */
@@ -51,13 +54,17 @@ export interface RunDreamAgentOptions {
}
/** Run the tool-calling dream loop. The subagent consolidates via ordinary file tools. */
-export async function runDreamAgent(options: RunDreamAgentOptions): Promise {
- return runToolAgent({
- model: options.model,
- tools: options.tools,
- toolCtx: options.toolCtx,
+export async function runDreamAgent(options: RunDreamAgentOptions): Promise {
+ return runMemoryAgent({
+ resolveModel: options.resolveModel,
+ tools: options.tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.inputSchema,
+ execute: (args) => tool.handler(args, options.toolCtx),
+ })),
systemPrompt: options.systemPrompt ?? DEFAULT_DREAM_SYSTEM_PROMPT,
- seedUserMessage: buildDreamAgentUserMessage({
+ userMessage: buildDreamAgentUserMessage({
health: options.health,
typeReview: options.typeReview,
manifest: options.manifest,
@@ -71,8 +78,8 @@ export async function runDreamAgent(options: RunDreamAgentOptions): Promise {
- return runToolAgent({
- model: options.model,
- tools: options.tools,
- toolCtx: options.toolCtx,
+ return runMemoryAgent({
+ resolveModel: options.resolveModel,
+ tools: options.tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.inputSchema,
+ execute: (args) => tool.handler(args, options.toolCtx),
+ })),
systemPrompt: options.systemPrompt ?? DEFAULT_EXTRACTION_SYSTEM_PROMPT,
- seedUserMessage: buildExtractionAgentUserMessage({
+ userMessage: buildExtractionAgentUserMessage({
messages: options.messages,
manifest: options.manifest,
}),
@@ -76,8 +76,8 @@ export async function runExtractionAgent(
/** Options for {@link createExtractionAgentRunner}. */
export interface CreateExtractionAgentRunnerOptions {
- /** The host-owned canonical model channel. */
- model: CanonicalModelCompletion;
+ /** Resolve the host's active pi-ai model binding for this run. */
+ resolveModel: ResolvePiAgentModel;
/** Override the tool-use system prompt. */
systemPrompt?: string;
/** Max model round-trips per extraction. Defaults to 12, hard-capped at 20. */
@@ -94,7 +94,7 @@ export function createExtractionAgentRunner(
): ExtractionAgentRunner {
return async function agentRunner(input) {
const result = await runExtractionAgent({
- model: options.model,
+ resolveModel: options.resolveModel,
tools: input.tools,
toolCtx: input.toolCtx,
systemPrompt: options.systemPrompt,
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index d323afd..b29c29e 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -17,7 +17,7 @@
* tool-calling loop: it calls ordinary file tools, which WRITE FILES directly.
* Dream consolidation is the same kind of subagent over the same channel. The
* SDK ships the extraction / dream / skill loops over a provider-neutral
- * canonical model contract. Providers and host runtimes live outside the SDK.
+ * host-resolved pi-ai model binding. Providers and host runtimes live outside the SDK.
*
* The host gathers the conversation turn into ExtractionMessage[] and calls the
* hooks; core does the rest (write lock, atomic writes, index sync, cursor).
@@ -93,40 +93,28 @@ export {
buildExtractionAgentUserMessage,
} from "@memflywheel/core";
-// Provider-neutral model protocol plus provider mappers.
+// Optional embedding infrastructure. Write-side LLM execution uses Pi Agent Core.
export {
- type JsonSchemaObject,
- type CanonicalModelRole,
- type CanonicalToolCall,
- type CanonicalModelMessage,
- type CanonicalToolDefinition,
- type CanonicalModelRequest,
- type CanonicalModelResponse,
- type CanonicalModelCompletion,
- type CanonicalModelComplete,
- type CanonicalEmbeddingProvider,
- type OpenAIChatCompletionsModelConfig,
type OpenAIEmbeddingsModelConfig,
- createOpenAIChatCompletionsModel,
createOpenAIEmbeddingsModel,
-} from "@memflywheel/model";
+} from "@memflywheel/embeddings";
-// Runtime assembly layer: the extraction & dream subagents over the canonical
-// model protocol (both the same tool-calling loop, seeded differently).
+// Runtime assembly layer: every write-side task runs on Pi Agent Core.
export {
- type RunToolAgentOptions,
- type ToolAgentResult,
- type AgentToolCall,
- runToolAgent,
- MAX_TOOL_AGENT_STEPS,
-} from "./tool-agent.js";
+ type PiAgentModelBinding,
+ type ResolvePiAgentModel,
+ type MemoryAgentToolResult,
+ type MemoryAgentTool,
+ type MemoryAgentResult,
+ type RunMemoryAgentOptions,
+ runMemoryAgent,
+} from "./agent-runner.js";
export {
type RunExtractionAgentOptions,
type ExtractionAgentResult,
type CreateExtractionAgentRunnerOptions,
runExtractionAgent,
createExtractionAgentRunner,
- MAX_EXTRACTION_STEPS,
} from "./extraction-agent.js";
export {
type RunDreamAgentOptions,
@@ -677,11 +665,7 @@ export function createMemFlywheel(config: MemFlywheelConfig = {}): MemFlywheel {
lastSkillEvolutionTurn.delete(sessionId);
// Count this ended session toward the dream gate's session threshold.
if (enabled) {
- try {
- await bumpDreamSessions(root);
- } catch {
- // Bookkeeping is best-effort; never let it break session teardown.
- }
+ await bumpDreamSessions(root);
}
});
},
@@ -701,9 +685,8 @@ export function createMemFlywheel(config: MemFlywheelConfig = {}): MemFlywheel {
async save(options: SaveOptions): Promise {
if (!enabled) return ExtractionResult.Skipped;
- const { acquireLock, releaseLock } = await import("@memflywheel/core");
+ const { acquireLock } = await import("@memflywheel/core");
const handle = await acquireLock(root, "save");
- if (!handle.acquired) return ExtractionResult.Queued;
try {
await ensureMemoryDir(root);
const toolCtx = createMemoryFileToolContext({ ctx, refuseSecrets });
@@ -732,7 +715,7 @@ export function createMemFlywheel(config: MemFlywheelConfig = {}): MemFlywheel {
await syncMemoryIndex(root);
return changed.length > 0 ? ExtractionResult.Completed : ExtractionResult.Skipped;
} finally {
- await releaseLock(root);
+ await handle.release();
}
},
diff --git a/packages/sdk/src/skill-evolution-agent.test.ts b/packages/sdk/src/skill-evolution-agent.test.ts
index 5029a96..0949df0 100644
--- a/packages/sdk/src/skill-evolution-agent.test.ts
+++ b/packages/sdk/src/skill-evolution-agent.test.ts
@@ -10,48 +10,12 @@ import {
type SkillEvolutionStore,
type SkillEvolutionTool,
} from "./skill-evolution-agent.js";
-import type {
- CanonicalModelCompletion,
- CanonicalModelRequest,
- CanonicalModelResponse,
-} from "@memflywheel/model";
-
-function scriptedModel(responses: CanonicalModelResponse[]): {
- model: CanonicalModelCompletion;
- requests: () => CanonicalModelRequest[];
-} {
- const seen: CanonicalModelRequest[] = [];
- let i = 0;
- const model: CanonicalModelCompletion = {
- complete: async (req) => {
- seen.push({
- ...req,
- messages: req.messages.map((message) => ({
- ...message,
- toolCalls: message.toolCalls?.map((call) => ({ ...call })),
- })),
- tools: req.tools.map((tool) => ({ ...tool })),
- });
- const response = responses[i];
- i += 1;
- if (!response) throw new Error("unexpected skill evolution completion call");
- return response;
- },
- };
- return { model, requests: () => seen };
-}
-
-function toolCallResponse(name: string, args: unknown, id = "c1"): CanonicalModelResponse {
- return {
- message: { role: "assistant", content: null, toolCalls: [{ id, name, input: args }] },
- finishReason: "tool-calls",
- };
-}
-
-const STOP_RESPONSE: CanonicalModelResponse = {
- message: { role: "assistant", content: "done" },
- finishReason: "stop",
-};
+import type { ResolvePiAgentModel } from "./agent-runner.js";
+import {
+ scriptedBinding,
+ stopTurn as STOP_RESPONSE,
+ toolTurn as toolCallResponse,
+} from "./agent-test-support.test.js";
/**
* A mock store whose tools operate on an in-memory staged file map seeded from the
@@ -178,9 +142,9 @@ function makeStore(opts: { catalog?: string[]; finalizeResult: LearnedSkillChang
const REVIEW = "memflywheel-learned-review";
const DEBUG = "memflywheel-learned-debug";
-function run(store: SkillEvolutionStore, model: CanonicalModelCompletion) {
+function run(store: SkillEvolutionStore, resolveModel: ResolvePiAgentModel) {
return runSkillEvolutionAgent({
- model,
+ resolveModel,
store,
sessionId: "s1",
reviewPacket: { summary: "review packet" },
@@ -195,12 +159,12 @@ test("runSkillEvolutionAgent: a new skill file derives a create coordination (no
catalog: [],
finalizeResult: { changedSkills: [REVIEW], changedFiles: [`${REVIEW}/SKILL.md`] },
});
- const { model, requests } = scriptedModel([
+ const { resolveModel, contexts } = scriptedBinding([
toolCallResponse("write", { filePath: `${REVIEW}/SKILL.md`, content: "new body" }, "u1"),
STOP_RESPONSE,
]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
assert.equal(state.checkpointed, 1);
assert.equal(state.finalized, 1);
@@ -213,7 +177,7 @@ test("runSkillEvolutionAgent: a new skill file derives a create coordination (no
assert.equal(result.coordination.memoryAction, "compress-memory");
assert.ok(result.coordination.memoryTopics.length > 0);
- const seed = String(requests()[0].messages[1].content);
+ const seed = JSON.stringify(contexts()[0]?.messages[0]?.content);
for (const section of [
/# Review packet/,
/# Learned skill index/,
@@ -223,8 +187,7 @@ test("runSkillEvolutionAgent: a new skill file derives a create coordination (no
]) {
assert.match(seed, section);
}
- const specs = requests()[0].tools;
- assert.ok(specs.every((tool) => tool.strict === true));
+ const specs = contexts()[0]?.tools ?? [];
assert.ok(specs.some((tool) => tool.name === "write"));
});
@@ -233,12 +196,12 @@ test("runSkillEvolutionAgent: editing an existing catalog skill derives an updat
catalog: [REVIEW],
finalizeResult: { changedSkills: [REVIEW], changedFiles: [`${REVIEW}/SKILL.md`] },
});
- const { model } = scriptedModel([
+ const { resolveModel } = scriptedBinding([
toolCallResponse("write", { filePath: `${REVIEW}/SKILL.md`, content: "improved body" }, "u1"),
STOP_RESPONSE,
]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
assert.equal(state.rolledBack, 0);
assert.equal(result.coordination.decision, "update");
@@ -250,12 +213,12 @@ test("runSkillEvolutionAgent: a skill change auto-links memory via a compress-me
catalog: [REVIEW],
finalizeResult: { changedSkills: [REVIEW], changedFiles: [`${REVIEW}/SKILL.md`] },
});
- const { model } = scriptedModel([
+ const { resolveModel } = scriptedBinding([
toolCallResponse("write", { filePath: `${REVIEW}/SKILL.md`, content: "improved" }, "u1"),
STOP_RESPONSE,
]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
// The memory↔skill link: a real skill change always triggers a follow-up memory
// compression with a topic derived from the skill.
@@ -269,9 +232,9 @@ test("runSkillEvolutionAgent: making no file change is a graceful noop, not a th
catalog: [],
finalizeResult: { changedSkills: [], changedFiles: [] },
});
- const { model } = scriptedModel([STOP_RESPONSE]);
+ const { resolveModel } = scriptedBinding([STOP_RESPONSE]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
assert.equal(state.finalized, 1);
assert.equal(state.rolledBack, 0);
@@ -284,13 +247,13 @@ test("runSkillEvolutionAgent: deleting a duplicate directory derives a merge coo
catalog: [REVIEW, DEBUG],
finalizeResult: { changedSkills: [REVIEW, DEBUG], changedFiles: [`${REVIEW}/SKILL.md`] },
});
- const { model } = scriptedModel([
+ const { resolveModel } = scriptedBinding([
toolCallResponse("write", { filePath: `${REVIEW}/SKILL.md`, content: "merged body" }, "u1"),
toolCallResponse("bash", { command: `rm -rf ${DEBUG}` }, "a1"),
STOP_RESPONSE,
]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
assert.equal(state.rolledBack, 0);
assert.equal(result.coordination.decision, "merge");
@@ -307,13 +270,13 @@ test("runSkillEvolutionAgent: keeps finalized skill changes even when multiple s
changedFiles: [`${REVIEW}/SKILL.md`, `${DEBUG}/SKILL.md`],
},
});
- const { model } = scriptedModel([
+ const { resolveModel } = scriptedBinding([
toolCallResponse("write", { filePath: `${REVIEW}/SKILL.md`, content: "a" }, "u1"),
toolCallResponse("write", { filePath: `${DEBUG}/SKILL.md`, content: "b" }, "u2"),
STOP_RESPONSE,
]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
assert.equal(state.rolledBack, 0);
assert.deepEqual(result.changedSkills, [REVIEW, DEBUG]);
@@ -335,19 +298,19 @@ test("runSkillEvolutionAgent: feeds skill tool validation errors back so the mod
return { ok: false, text: "filePath must be memflywheel-learned-/SKILL.md" };
return originalWrite(args);
};
- const { model, requests } = scriptedModel([
+ const { resolveModel, contexts } = scriptedBinding([
toolCallResponse("write", { filePath: "review/SKILL.md", content: "bad" }, "bad"),
toolCallResponse("write", { filePath: `${REVIEW}/SKILL.md`, content: "good" }, "good"),
STOP_RESPONSE,
]);
- const result = await run(store, model);
+ const result = await run(store, resolveModel);
assert.equal(state.rolledBack, 0);
assert.equal(result.coordination.targetSkill, REVIEW);
// the tool error was surfaced back to the model as a tool result
assert.match(
- String(requests()[1].messages.at(-1)?.content),
+ JSON.stringify(contexts()[1]?.messages.at(-1)?.content),
/memflywheel-learned-\/SKILL\.md/,
);
});
diff --git a/packages/sdk/src/skill-evolution-agent.ts b/packages/sdk/src/skill-evolution-agent.ts
index 2823f31..0a360d7 100644
--- a/packages/sdk/src/skill-evolution-agent.ts
+++ b/packages/sdk/src/skill-evolution-agent.ts
@@ -8,16 +8,7 @@
* and actual changed skills agree.
*/
-import { clampSteps } from "./tool-agent.js";
-import type {
- CanonicalModelCompletion,
- CanonicalModelMessage,
- CanonicalToolDefinition,
- JsonSchemaObject,
-} from "@memflywheel/model";
-
-const DEFAULT_MAX_STEPS = 12;
-const MAX_TOOL_RESULT_CHARS = 4000;
+import { type ResolvePiAgentModel, runMemoryAgent } from "./agent-runner.js";
export const DEFAULT_SKILL_EVOLUTION_SYSTEM_PROMPT = `You are a learned-skill evolution agent. You improve durable, reusable executable methods by editing learned-skill files with ordinary file tools. You do NOT report decisions as JSON — the system derives exactly what you did from the files you change.
@@ -144,11 +135,16 @@ export interface SkillEvolutionTool {
name: string;
description: string;
inputSchema:
- | JsonSchemaObject
| {
type: "object";
properties: Record;
- required?: string[];
+ required: readonly string[];
+ additionalProperties: false;
+ }
+ | {
+ type: "object";
+ properties: Record;
+ required?: readonly string[];
additionalProperties?: boolean;
};
handler: (args: unknown) => Promise;
@@ -186,7 +182,7 @@ export interface SkillEvolutionStore {
}
export interface RunSkillEvolutionAgentOptions {
- model: CanonicalModelCompletion;
+ resolveModel: ResolvePiAgentModel;
store: SkillEvolutionStore;
sessionId: string;
reviewPacket: unknown;
@@ -203,16 +199,9 @@ export interface SkillEvolutionAgentResult extends LearnedSkillChangeSet {
coordination: SkillEvolutionCoordination;
learnedSkillIndex: LearnedSkillsCatalog;
toolCalls: string[];
- stoppedReason: "no-tool-calls" | "max-steps" | "aborted";
steps: number;
}
-function clipToolResult(text: string): string {
- return text.length > MAX_TOOL_RESULT_CHARS
- ? `${text.slice(0, MAX_TOOL_RESULT_CHARS)}\n...(truncated)`
- : text;
-}
-
function assertRecord(value: unknown, label: string): Record {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
@@ -389,20 +378,6 @@ export function validateSkillEvolutionChangeSet(
return changeSet;
}
-function toToolSpecs(tools: SkillEvolutionTool[]): CanonicalToolDefinition[] {
- return tools.map((tool) => ({
- name: tool.name,
- description: tool.description,
- strict: true,
- inputSchema: {
- type: "object",
- properties: tool.inputSchema.properties,
- required: tool.inputSchema.required ?? [],
- additionalProperties: false,
- },
- }));
-}
-
/**
* Project the catalog down to relative, model-safe fields. Critically this DROPS the
* absolute `skillsRoot` path (and any other absolute paths): the model must only ever
@@ -532,79 +507,6 @@ function deriveCoordination(input: {
};
}
-async function runSkillToolLoop(input: {
- model: CanonicalModelCompletion;
- tools: SkillEvolutionTool[];
- systemPrompt: string;
- seedUserMessage: string;
- maxSteps?: number;
- signal?: AbortSignal;
-}): Promise<{
- steps: number;
- toolCalls: string[];
- finalContent: string | null;
- stoppedReason: "no-tool-calls" | "max-steps" | "aborted";
-}> {
- const maxSteps = clampSteps(
- typeof input.maxSteps === "number" ? input.maxSteps : DEFAULT_MAX_STEPS,
- );
- const lookup = new Map(input.tools.map((tool) => [tool.name, tool]));
- const messages: CanonicalModelMessage[] = [
- { role: "system", content: input.systemPrompt },
- { role: "user", content: input.seedUserMessage },
- ];
- const specs = toToolSpecs(input.tools);
- const toolCalls: string[] = [];
- let steps = 0;
-
- for (let step = 0; step < maxSteps; step += 1) {
- if (input.signal?.aborted)
- return { steps, toolCalls, finalContent: null, stoppedReason: "aborted" };
-
- const response = await input.model.complete({
- messages,
- tools: specs,
- signal: input.signal,
- });
- steps += 1;
- messages.push(response.message);
-
- const calls = response.message.toolCalls ?? [];
- if (calls.length === 0) {
- return {
- steps,
- toolCalls,
- finalContent:
- typeof response.message.content === "string" ? response.message.content : null,
- stoppedReason: "no-tool-calls",
- };
- }
-
- for (const call of calls) {
- const tool = lookup.get(call.name);
- if (!tool) throw new Error(`unknown skill evolution tool: ${call.name}`);
-
- let result: SkillEvolutionToolResult;
- try {
- result = await tool.handler(call.input ?? {});
- } catch (error) {
- result = {
- ok: false,
- text: error instanceof Error ? error.message : String(error),
- };
- }
- toolCalls.push(call.name);
- messages.push({
- role: "tool",
- toolCallId: call.id,
- content: clipToolResult(result.ok ? result.text : `error: ${result.text}`),
- });
- }
- }
-
- return { steps, toolCalls, finalContent: null, stoppedReason: "max-steps" };
-}
-
export async function runSkillEvolutionAgent(
options: RunSkillEvolutionAgentOptions,
): Promise {
@@ -622,11 +524,21 @@ export async function runSkillEvolutionAgent(
try {
const tools = options.store.createFileTools(checkpoint);
- const loop = await runSkillToolLoop({
- model: options.model,
- tools,
+ const loop = await runMemoryAgent({
+ resolveModel: options.resolveModel,
+ tools: tools.map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ inputSchema: {
+ type: "object",
+ properties: tool.inputSchema.properties,
+ required: tool.inputSchema.required ?? [],
+ additionalProperties: false,
+ },
+ execute: (args) => tool.handler(args),
+ })),
systemPrompt: options.systemPrompt ?? DEFAULT_SKILL_EVOLUTION_SYSTEM_PROMPT,
- seedUserMessage: buildSkillEvolutionUserMessage({
+ userMessage: buildSkillEvolutionUserMessage({
reviewPacket: options.reviewPacket,
learnedSkillIndex,
toolTrajectory: options.toolTrajectory,
@@ -665,8 +577,7 @@ export async function runSkillEvolutionAgent(
...changeSet,
coordination,
learnedSkillIndex,
- toolCalls: loop.toolCalls,
- stoppedReason: loop.stoppedReason,
+ toolCalls: loop.toolCalls.map((call) => call.name),
steps: loop.steps,
};
} catch (err) {
diff --git a/packages/sdk/src/tool-agent.ts b/packages/sdk/src/tool-agent.ts
deleted file mode 100644
index 3b70959..0000000
--- a/packages/sdk/src/tool-agent.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * The shared tool-calling agent loop.
- *
- * Both memory subagents — extraction and dream — are the same loop: seed it with
- * a system prompt + one user message, advertise ordinary file tools, then drive the
- * model round by round. Each round executes any requested tool calls (which WRITE
- * FILES via core's handlers) and feeds the results back as role:"tool" messages,
- * until the model stops requesting tools or the step cap is reached. The loop
- * never throws for tool errors — handlers return { ok:false } results the
- * subagent can read.
- *
- * Hardening (borrowed from mature agent loops): a hard step cap (≤ 20) so the
- * loop can never run away; exponential-backoff retries on transient completion
- * errors (rate limit / 5xx / network) so a transport blip doesn't fail the whole
- * pass; an abort check before every step for graceful cancellation; and clipping
- * of each tool result fed back, to bound context growth. A completion error that
- * is not transient (or survives retries) propagates, which the caller treats as
- * a failed pass.
- */
-
-import { type FileTool, type FileToolContext, fileToolMap } from "@memflywheel/core";
-
-import type {
- CanonicalModelCompletion,
- CanonicalModelMessage,
- CanonicalModelRequest,
- CanonicalModelResponse,
- CanonicalToolDefinition,
-} from "@memflywheel/model";
-
-const DEFAULT_MAX_STEPS = 12;
-/** Hard cap on tool-agent loop rounds. A memory subagent must never run away. */
-export const MAX_TOOL_AGENT_STEPS = 20;
-/** Retries (beyond the first try) for transient transport/provider errors. */
-const RETRY_ATTEMPTS = 2;
-const RETRY_BASE_DELAY_MS = 500;
-/** Clip a single tool result fed back to the model, to bound context growth. */
-const MAX_TOOL_RESULT_CHARS = 4000;
-
-export function clampSteps(n: number): number {
- if (!Number.isFinite(n) || n < 1) return DEFAULT_MAX_STEPS;
- return Math.min(Math.trunc(n), MAX_TOOL_AGENT_STEPS);
-}
-
-/** Transient transport/provider error worth retrying (rate limit / 5xx / network). */
-export function isRetryableError(err: unknown): boolean {
- const msg = String((err as Error)?.message ?? err).toLowerCase();
- return /\b(408|409|425|429|5\d\d)\b|rate.?limit|overloaded|too many requests|timeout|timed out|temporarily|unavailable|econnreset|etimedout|enotfound|socket hang up|network|fetch failed/.test(
- msg,
- );
-}
-
-function sleep(ms: number, signal?: AbortSignal): Promise {
- return new Promise((resolve, reject) => {
- if (signal?.aborted) return reject(new Error("aborted"));
- const timer = setTimeout(resolve, ms);
- signal?.addEventListener(
- "abort",
- () => {
- clearTimeout(timer);
- reject(new Error("aborted"));
- },
- { once: true },
- );
- });
-}
-
-/** Call the tool completion with exponential-backoff retries on transient errors. */
-async function completeWithRetries(
- model: CanonicalModelCompletion,
- req: CanonicalModelRequest,
- signal?: AbortSignal,
-): Promise {
- let lastErr: unknown;
- for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt += 1) {
- try {
- return await model.complete(req);
- } catch (err) {
- lastErr = err;
- if (signal?.aborted || attempt === RETRY_ATTEMPTS || !isRetryableError(err)) break;
- await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt, signal);
- }
- }
- throw lastErr;
-}
-
-function clipToolResult(text: string): string {
- return text.length > MAX_TOOL_RESULT_CHARS
- ? `${text.slice(0, MAX_TOOL_RESULT_CHARS)}\n…(truncated)`
- : text;
-}
-
-/** One executed tool call's outcome, surfaced for accounting. */
-export interface AgentToolCall {
- name: string;
- ok: boolean;
-}
-
-/** Options for {@link runToolAgent}. */
-export interface RunToolAgentOptions {
- /** The host-owned canonical model channel. */
- model: CanonicalModelCompletion;
- /** The file tools (from core.createFileTools()), advertised + executed. */
- tools: FileTool[];
- /** The context the handlers write through (shares the held lock). */
- toolCtx: FileToolContext;
- /** The system prompt seeding the loop. */
- systemPrompt: string;
- /** The single seed user message rendering the task packet. */
- seedUserMessage: string;
- /** Max model round-trips before the loop stops. Defaults to 12, hard-capped at 20. */
- maxSteps?: number;
- /** Abort signal threaded into each round-trip. */
- signal?: AbortSignal;
-}
-
-/** Outcome of a tool-agent run. */
-export interface ToolAgentResult {
- /** Number of model round-trips taken. */
- steps: number;
- /** Every executed tool call, in order. */
- toolCalls: AgentToolCall[];
- /** Union of all handlers' changed relative paths. */
- changed: string[];
- /** Why the loop stopped. */
- stoppedReason: "no-tool-calls" | "max-steps" | "aborted";
-}
-
-/** Map core file tools to canonical tool definitions. */
-function toToolSpecs(tools: FileTool[]): CanonicalToolDefinition[] {
- return tools.map((tool) => ({
- name: tool.name,
- description: tool.description,
- inputSchema: tool.inputSchema,
- }));
-}
-
-/**
- * Run the tool-calling loop. The subagent decides what to persist and calls the
- * ordinary file tools, which write files directly. Pure orchestration: it does not own
- * the lock, cursor, or index — the core session closure does.
- */
-export async function runToolAgent(options: RunToolAgentOptions): Promise {
- // Clamp to the hard cap (≤ MAX_TOOL_AGENT_STEPS): the loop must never run away.
- const maxSteps = clampSteps(
- typeof options.maxSteps === "number" ? options.maxSteps : DEFAULT_MAX_STEPS,
- );
-
- const toolSpecs = toToolSpecs(options.tools);
- const lookup = fileToolMap(options.tools);
-
- const messages: CanonicalModelMessage[] = [
- { role: "system", content: options.systemPrompt },
- { role: "user", content: options.seedUserMessage },
- ];
-
- const toolCalls: AgentToolCall[] = [];
- const changed: string[] = [];
- let steps = 0;
-
- for (let step = 0; step < maxSteps; step += 1) {
- if (options.signal?.aborted) {
- return { steps, toolCalls, changed, stoppedReason: "aborted" };
- }
- const response = await completeWithRetries(
- options.model,
- { messages, tools: toolSpecs, signal: options.signal },
- options.signal,
- );
- steps += 1;
- messages.push(response.message);
-
- const calls = response.message.toolCalls ?? [];
- if (calls.length === 0) {
- return { steps, toolCalls, changed, stoppedReason: "no-tool-calls" };
- }
-
- for (const call of calls) {
- const tool = lookup.get(call.name);
- let result;
- if (!tool) {
- result = { ok: false, text: `unknown tool: ${call.name}` };
- } else {
- result = await tool.handler(call.input ?? {}, options.toolCtx);
- }
- toolCalls.push({ name: call.name, ok: result.ok });
- if (result.changed && result.changed.length > 0) changed.push(...result.changed);
- messages.push({
- role: "tool",
- toolCallId: call.id,
- content: clipToolResult(result.text),
- });
- }
- }
-
- return { steps, toolCalls, changed, stoppedReason: "max-steps" };
-}
diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json
index 5ec3b0c..4540308 100644
--- a/packages/sdk/tsconfig.json
+++ b/packages/sdk/tsconfig.json
@@ -5,5 +5,5 @@
"outDir": "dist"
},
"include": ["src/**/*.ts"],
- "references": [{ "path": "../core" }, { "path": "../model" }]
+ "references": [{ "path": "../core" }, { "path": "../embeddings" }]
}
diff --git a/packages/skills/package.json b/packages/skills/package.json
index 667bc87..e9bc683 100644
--- a/packages/skills/package.json
+++ b/packages/skills/package.json
@@ -1,6 +1,6 @@
{
"name": "@memflywheel/skills",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"description": "File-native learned skill layer for MemFlywheel agent runtimes.",
"license": "Apache-2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7a54daa..fd3b8e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,15 @@ importers:
.:
devDependencies:
+ '@memflywheel/embeddings':
+ specifier: workspace:*
+ version: link:packages/embeddings
+ '@memflywheel/sdk':
+ specifier: workspace:*
+ version: link:packages/sdk
+ '@memflywheel/skills':
+ specifier: workspace:*
+ version: link:packages/skills
'@types/node':
specifier: ^22.20.1
version: 22.20.1
@@ -47,9 +56,6 @@ importers:
'@memflywheel/core':
specifier: workspace:*
version: link:../packages/core
- '@memflywheel/model':
- specifier: workspace:*
- version: link:../packages/model
'@memflywheel/sdk':
specifier: workspace:*
version: link:../packages/sdk
@@ -58,42 +64,44 @@ importers:
version: link:../packages/skills
packages/adapters:
+ dependencies:
+ '@earendil-works/pi-agent-core':
+ specifier: ^0.82.1
+ version: 0.82.1(ws@8.21.1)(zod@4.4.3)
+ '@earendil-works/pi-ai':
+ specifier: ^0.82.1
+ version: 0.82.1(ws@8.21.1)(zod@4.4.3)
+ proper-lockfile:
+ specifier: ^4.1.2
+ version: 4.1.2
devDependencies:
- '@memflywheel/model':
- specifier: workspace:*
- version: link:../model
- '@memflywheel/sdk':
- specifier: workspace:*
- version: link:../sdk
- '@memflywheel/skills':
- specifier: workspace:*
- version: link:../skills
'@types/node':
specifier: ^22.20.1
version: 22.20.1
tsup:
specifier: ^8.5.1
- version: 8.5.1(typescript@5.9.3)
+ version: 8.5.1(typescript@5.9.3)(yaml@2.9.0)
typescript:
specifier: ^5.5.4
version: 5.9.3
packages/core:
+ dependencies:
+ proper-lockfile:
+ specifier: ^4.1.2
+ version: 4.1.2
devDependencies:
'@types/node':
specifier: ^22.20.1
version: 22.20.1
+ '@types/proper-lockfile':
+ specifier: ^4.1.4
+ version: 4.1.4
typescript:
specifier: ^5.5.4
version: 5.9.3
- packages/hermes-plugin:
- dependencies:
- '@iflytekopensource/adapters':
- specifier: workspace:*
- version: link:../adapters
-
- packages/model:
+ packages/embeddings:
devDependencies:
'@types/node':
specifier: ^22.20.1
@@ -102,14 +110,29 @@ importers:
specifier: ^5.5.4
version: 5.9.3
+ packages/hermes-plugin:
+ dependencies:
+ '@earendil-works/pi-ai':
+ specifier: ^0.82.1
+ version: 0.82.1(ws@8.21.1)(zod@4.4.3)
+ '@iflytekopensource/adapters':
+ specifier: workspace:*
+ version: link:../adapters
+
packages/sdk:
dependencies:
+ '@earendil-works/pi-agent-core':
+ specifier: ^0.82.1
+ version: 0.82.1(ws@8.21.1)(zod@4.4.3)
+ '@earendil-works/pi-ai':
+ specifier: ^0.82.1
+ version: 0.82.1(ws@8.21.1)(zod@4.4.3)
'@memflywheel/core':
specifier: workspace:*
version: link:../core
- '@memflywheel/model':
+ '@memflywheel/embeddings':
specifier: workspace:*
- version: link:../model
+ version: link:../embeddings
devDependencies:
'@types/node':
specifier: ^22.20.1
@@ -133,6 +156,125 @@ importers:
packages:
+ '@anthropic-ai/sdk@0.91.1':
+ resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==}
+ hasBin: true
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
+ '@aws-crypto/sha256-browser@5.2.0':
+ resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
+
+ '@aws-crypto/sha256-js@5.2.0':
+ resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
+ engines: {node: '>=16.0.0'}
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
+
+ '@aws-crypto/util@5.2.0':
+ resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
+
+ '@aws-sdk/client-bedrock-runtime@3.1048.0':
+ resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/core@3.977.1':
+ resolution: {integrity: sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-env@3.972.61':
+ resolution: {integrity: sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-http@3.972.63':
+ resolution: {integrity: sha512-yfozsS8wkWZEi/n6IsrodcFKBWZ0iNAezhJbTReMNc0z1Px17qdeAeuL1/wziCAmCZyXiW7QzP75ggJkBQv8jQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-ini@3.973.6':
+ resolution: {integrity: sha512-jGLTW1bj148GL/6/IMlfY2fMYS9FtHOG+NahkFD4y0qkzYudNUahelxryY68/HGMslYuHClk1XaS/3b3eJzEkg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-login@3.972.68':
+ resolution: {integrity: sha512-w6tNci6g7RqFpLhj1f5xseBvaNojb4Pkgp5Jp5apl9hrJtaf2AA+rX9+qlhlWUK6kcyAFYPA7emO+55zj+S98Q==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-node@3.972.72':
+ resolution: {integrity: sha512-blQ7F5QGzylnzeh5549zQLoCAiMHkXFLjFovEMaVy4b2X8JhUu+u9NXro1hyK95YHdVFNmBHKs2hIHtZchxKlQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-process@3.972.61':
+ resolution: {integrity: sha512-xzRuj+fUVO4nkafKQJVKAF97kGpeQbfjuwmRrtGZNf42/1dkmcz6o7dswBy7alY0htQn5sCL1GWQYEykviWZkA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-sso@3.973.5':
+ resolution: {integrity: sha512-fZRjjWhLFelsDoOYjqShQTrIGYC3Pf9Mx9Czf+1ikfQDgktxjze33dVo1q1/ZQ+T0qbtejVoHNHrfD5aJVpv/w==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-web-identity@3.972.67':
+ resolution: {integrity: sha512-FTNZ05gkPBA6CKbU3N4zPgybV+stdazwMOya75CmGdcJL7p8Fw/BdHP8WVxJd0mvzyPK2cg/C3gli58Ir4HgCw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/eventstream-handler-node@3.972.30':
+ resolution: {integrity: sha512-hJboPgIpq5+ADc++/B9TBqn65CXV21cZLGB8V5RBQbxkZ/rQ6qMfcxTnW/SvQlasX4jhaSG8B1wsVjhQyDrsnQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/middleware-eventstream@3.972.25':
+ resolution: {integrity: sha512-9SFbPzJDHHR5k6Q6KvXVas/veUm/TzNcNTFM2UhdXHZHpyIvI2lS+s4cxljw1BihGpVhsAkQDo/2nW7dHxpf4Q==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/middleware-websocket@3.972.43':
+ resolution: {integrity: sha512-n29++15Vma64Kd0enp9Bo8a6LTm8TvUoMbJEwqXtIksv0oEs+SUCRMm3gozDfPD1Ly0k/sSBughxarlLlRF6Xw==}
+ engines: {node: '>= 14.0.0'}
+
+ '@aws-sdk/nested-clients@3.997.35':
+ resolution: {integrity: sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/signature-v4-multi-region@3.996.42':
+ resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/token-providers@3.1048.0':
+ resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/token-providers@3.1095.0':
+ resolution: {integrity: sha512-65SudS6y4nzaYHybtqcpm3sHe5jLhdMn68HRKS1nUx690BtQeaAQOoujQ+dpOjBATIVGVgKKjEP8tR+U06QJQA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/types@3.974.2':
+ resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/util-locate-window@3.965.8':
+ resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/xml-builder@3.972.37':
+ resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws/lambda-invoke-store@0.3.0':
+ resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@earendil-works/pi-agent-core@0.82.1':
+ resolution: {integrity: sha512-Z3kloziJIE2dmrisRckZX8zDca/gIv9/YdFAzeoqpHiLV2wsni6bL4hInNSjVKLbqT+4kqLIkph2JQLKvSepjg==}
+ engines: {node: '>=22.19.0'}
+
+ '@earendil-works/pi-ai@0.82.1':
+ resolution: {integrity: sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==}
+ engines: {node: '>=22.19.0'}
+ hasBin: true
+
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -319,6 +461,15 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ '@google/genai@1.52.0':
+ resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ '@modelcontextprotocol/sdk': ^1.25.2
+ peerDependenciesMeta:
+ '@modelcontextprotocol/sdk':
+ optional: true
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -352,10 +503,53 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mistralai/mistralai@2.2.6':
+ resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==}
+ peerDependencies:
+ '@opentelemetry/api': ^1.9.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+
+ '@opentelemetry/api@1.9.0':
+ resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/semantic-conventions@1.43.0':
+ resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
+ engines: {node: '>=14'}
+
'@pkgr/core@0.3.6':
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
engines: {node: ^14.18.0 || >=16.0.0}
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.5':
+ resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
+
+ '@protobufjs/eventemitter@1.1.1':
+ resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
+
+ '@protobufjs/fetch@1.1.1':
+ resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.2':
+ resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
+
'@rollup/rollup-android-arm-eabi@4.62.2':
resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
cpu: [arm]
@@ -494,6 +688,46 @@ packages:
cpu: [x64]
os: [win32]
+ '@smithy/core@3.30.0':
+ resolution: {integrity: sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/credential-provider-imds@4.4.14':
+ resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/fetch-http-handler@5.6.11':
+ resolution: {integrity: sha512-o0Zkj1nKqJAoq+a+BrkhU39tRftMNjLwpc/z06Frfl43wpbHrJMaSAVZE4vTqlxtVkNaGaT0bIDxOp7tkFTuQQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/is-array-buffer@2.2.0':
+ resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/node-http-handler@4.7.3':
+ resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/node-http-handler@4.9.11':
+ resolution: {integrity: sha512-slbzbz8taEOzoXv/9y34YNBoE+ZHmddLykCgjDAjvMAsu2nM5s2Gzwa5OGF721tD8s+CKeFUBds5lSj9lcbuDg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/signature-v4@5.6.10':
+ resolution: {integrity: sha512-EXhWePm3SXJAX38npIy4TXL2Aex/OVgCClTjelN2QHw/U+8CQUH8C7AaxsVzQcYieYPseywn48s89DmvuGjiAg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/types@4.16.1':
+ resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-buffer-from@2.2.0':
+ resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/util-utf8@2.3.0':
+ resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
+ engines: {node: '>=14.0.0'}
+
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -506,6 +740,12 @@ packages:
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+ '@types/proper-lockfile@4.1.4':
+ resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
+
+ '@types/retry@0.12.0':
+ resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
+
'@typescript-eslint/eslint-plugin@8.65.0':
resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -575,6 +815,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ agent-base@7.1.4:
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+ engines: {node: '>= 14'}
+
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -585,10 +829,22 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ bignumber.js@9.3.1:
+ resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
+
+ bowser@2.14.1:
+ resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
+
brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
engines: {node: 20 || >=22}
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -618,6 +874,10 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ data-uri-to-buffer@4.0.1:
+ resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+ engines: {node: '>= 12'}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -630,6 +890,13 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+ engines: {node: '>=0.3.1'}
+
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
@@ -701,6 +968,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -722,6 +992,10 @@ packages:
picomatch:
optional: true
+ fetch-blob@3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+ engines: {node: ^12.20 || >= 14.13}
+
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -740,19 +1014,54 @@ packages:
flatted@3.4.3:
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
+ formdata-polyfill@4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ gaxios@7.3.0:
+ resolution: {integrity: sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==}
+ engines: {node: '>=18'}
+
+ gcp-metadata@8.1.2:
+ resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
+ engines: {node: '>=18'}
+
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
+ google-auth-library@10.9.1:
+ resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==}
+ engines: {node: '>=18'}
+
+ google-logging-utils@1.1.3:
+ resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
+ engines: {node: '>=14'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+
+ https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
ignore@7.0.6:
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'}
@@ -776,15 +1085,28 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
+ json-bigint@1.0.0:
+ resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+ json-schema-to-ts@3.1.1:
+ resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
+ engines: {node: '>=16'}
+
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -807,6 +1129,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -826,10 +1151,31 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ node-domexception@1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ deprecated: Use your platform's native DOMException instead
+
+ node-fetch@3.3.2:
+ resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ openai@6.26.0:
+ resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
+ hasBin: true
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -842,6 +1188,13 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-retry@4.6.2:
+ resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
+ engines: {node: '>=8'}
+
+ partial-json@0.1.7:
+ resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -898,6 +1251,13 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ proper-lockfile@4.1.2:
+ resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
+
+ protobufjs@7.6.5:
+ resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
+ engines: {node: '>=12.0.0'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -910,11 +1270,22 @@ packages:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
+ retry@0.12.0:
+ resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
+ engines: {node: '>= 4'}
+
+ retry@0.13.1:
+ resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
+ engines: {node: '>= 4'}
+
rollup@4.62.2:
resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
@@ -928,6 +1299,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
+ signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
@@ -959,6 +1333,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
+ ts-algebra@2.0.0:
+ resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -968,6 +1345,9 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'}
@@ -991,6 +1371,9 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
+ typebox@1.1.38:
+ resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==}
+
typebox@1.3.8:
resolution: {integrity: sha512-xYaJgF0KMvBViKWRaKTAtfR6sDt/yH6xAjGAHXYJxKxUF4pVyPXZZhIlwOg6cDIE81N7L0pyIHs136RHBm6rLQ==}
@@ -1008,6 +1391,10 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ web-streams-polyfill@3.3.3:
+ resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
+ engines: {node: '>= 8'}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1017,12 +1404,295 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ ws@8.21.1:
+ resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
snapshots:
+ '@anthropic-ai/sdk@0.91.1(zod@4.4.3)':
+ dependencies:
+ json-schema-to-ts: 3.1.1
+ optionalDependencies:
+ zod: 4.4.3
+
+ '@aws-crypto/sha256-browser@5.2.0':
+ dependencies:
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-crypto/supports-web-crypto': 5.2.0
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.974.2
+ '@aws-sdk/util-locate-window': 3.965.8
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-crypto/sha256-js@5.2.0':
+ dependencies:
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.974.2
+ tslib: 2.8.1
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-crypto/util@5.2.0':
+ dependencies:
+ '@aws-sdk/types': 3.974.2
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-sdk/client-bedrock-runtime@3.1048.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/credential-provider-node': 3.972.72
+ '@aws-sdk/eventstream-handler-node': 3.972.30
+ '@aws-sdk/middleware-eventstream': 3.972.25
+ '@aws-sdk/middleware-websocket': 3.972.43
+ '@aws-sdk/token-providers': 3.1048.0
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/fetch-http-handler': 5.6.11
+ '@smithy/node-http-handler': 4.9.11
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/core@3.977.1':
+ dependencies:
+ '@aws-sdk/types': 3.974.2
+ '@aws-sdk/xml-builder': 3.972.37
+ '@aws/lambda-invoke-store': 0.3.0
+ '@smithy/core': 3.30.0
+ '@smithy/signature-v4': 5.6.10
+ '@smithy/types': 4.16.1
+ bowser: 2.14.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-env@3.972.61':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-http@3.972.63':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/fetch-http-handler': 5.6.11
+ '@smithy/node-http-handler': 4.9.11
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-ini@3.973.6':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/credential-provider-env': 3.972.61
+ '@aws-sdk/credential-provider-http': 3.972.63
+ '@aws-sdk/credential-provider-login': 3.972.68
+ '@aws-sdk/credential-provider-process': 3.972.61
+ '@aws-sdk/credential-provider-sso': 3.973.5
+ '@aws-sdk/credential-provider-web-identity': 3.972.67
+ '@aws-sdk/nested-clients': 3.997.35
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/credential-provider-imds': 4.4.14
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-login@3.972.68':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.35
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-node@3.972.72':
+ dependencies:
+ '@aws-sdk/credential-provider-env': 3.972.61
+ '@aws-sdk/credential-provider-http': 3.972.63
+ '@aws-sdk/credential-provider-ini': 3.973.6
+ '@aws-sdk/credential-provider-process': 3.972.61
+ '@aws-sdk/credential-provider-sso': 3.973.5
+ '@aws-sdk/credential-provider-web-identity': 3.972.67
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/credential-provider-imds': 4.4.14
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-process@3.972.61':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-sso@3.973.5':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.35
+ '@aws-sdk/token-providers': 3.1095.0
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-web-identity@3.972.67':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.35
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/eventstream-handler-node@3.972.30':
+ dependencies:
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-eventstream@3.972.25':
+ dependencies:
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-websocket@3.972.43':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/fetch-http-handler': 5.6.11
+ '@smithy/signature-v4': 5.6.10
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/nested-clients@3.997.35':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/signature-v4-multi-region': 3.996.42
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/fetch-http-handler': 5.6.11
+ '@smithy/node-http-handler': 4.9.11
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/signature-v4-multi-region@3.996.42':
+ dependencies:
+ '@aws-sdk/types': 3.974.2
+ '@smithy/signature-v4': 5.6.10
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.1048.0':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.35
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.1095.0':
+ dependencies:
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.35
+ '@aws-sdk/types': 3.974.2
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/types@3.974.2':
+ dependencies:
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws-sdk/util-locate-window@3.965.8':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-sdk/xml-builder@3.972.37':
+ dependencies:
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@aws/lambda-invoke-store@0.3.0': {}
+
+ '@babel/runtime@7.29.7': {}
+
+ '@earendil-works/pi-agent-core@0.82.1(ws@8.21.1)(zod@4.4.3)':
+ dependencies:
+ '@earendil-works/pi-ai': 0.82.1(ws@8.21.1)(zod@4.4.3)
+ diff: 8.0.4
+ ignore: 7.0.5
+ typebox: 1.1.38
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - '@modelcontextprotocol/sdk'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - ws
+ - zod
+
+ '@earendil-works/pi-ai@0.82.1(ws@8.21.1)(zod@4.4.3)':
+ dependencies:
+ '@anthropic-ai/sdk': 0.91.1(zod@4.4.3)
+ '@aws-sdk/client-bedrock-runtime': 3.1048.0
+ '@google/genai': 1.52.0
+ '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
+ '@opentelemetry/api': 1.9.0
+ '@smithy/node-http-handler': 4.7.3
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ openai: 6.26.0(ws@8.21.1)(zod@4.4.3)
+ partial-json: 0.1.7
+ typebox: 1.1.38
+ transitivePeerDependencies:
+ - '@modelcontextprotocol/sdk'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - ws
+ - zod
+
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -1131,6 +1801,17 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
+ '@google/genai@1.52.0':
+ dependencies:
+ google-auth-library: 10.9.1
+ p-retry: 4.6.2
+ protobufjs: 7.6.5
+ ws: 8.21.1
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -1161,8 +1842,44 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/semantic-conventions': 1.43.0
+ ws: 8.21.1
+ zod: 4.4.3
+ zod-to-json-schema: 3.25.2(zod@4.4.3)
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@opentelemetry/api@1.9.0': {}
+
+ '@opentelemetry/semantic-conventions@1.43.0': {}
+
'@pkgr/core@0.3.6': {}
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.5': {}
+
+ '@protobufjs/eventemitter@1.1.1': {}
+
+ '@protobufjs/fetch@1.1.1':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.2': {}
+
'@rollup/rollup-android-arm-eabi@4.62.2':
optional: true
@@ -1238,6 +1955,59 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
+ '@smithy/core@3.30.0':
+ dependencies:
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@smithy/credential-provider-imds@4.4.14':
+ dependencies:
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@smithy/fetch-http-handler@5.6.11':
+ dependencies:
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@smithy/is-array-buffer@2.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/node-http-handler@4.7.3':
+ dependencies:
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@smithy/node-http-handler@4.9.11':
+ dependencies:
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@smithy/signature-v4@5.6.10':
+ dependencies:
+ '@smithy/core': 3.30.0
+ '@smithy/types': 4.16.1
+ tslib: 2.8.1
+
+ '@smithy/types@4.16.1':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-buffer-from@2.2.0':
+ dependencies:
+ '@smithy/is-array-buffer': 2.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-utf8@2.3.0':
+ dependencies:
+ '@smithy/util-buffer-from': 2.2.0
+ tslib: 2.8.1
+
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {}
@@ -1248,6 +2018,12 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/proper-lockfile@4.1.4':
+ dependencies:
+ '@types/retry': 0.12.0
+
+ '@types/retry@0.12.0': {}
+
'@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0)(typescript@5.9.3))(eslint@10.8.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -1345,6 +2121,8 @@ snapshots:
acorn@8.17.0: {}
+ agent-base@7.1.4: {}
+
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -1356,10 +2134,18 @@ snapshots:
balanced-match@4.0.4: {}
+ base64-js@1.5.1: {}
+
+ bignumber.js@9.3.1: {}
+
+ bowser@2.14.1: {}
+
brace-expansion@5.0.8:
dependencies:
balanced-match: 4.0.4
+ buffer-equal-constant-time@1.0.1: {}
+
bundle-require@5.1.0(esbuild@0.28.1):
dependencies:
esbuild: 0.28.1
@@ -1383,12 +2169,20 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ data-uri-to-buffer@4.0.1: {}
+
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-is@0.1.4: {}
+ diff@8.0.4: {}
+
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
@@ -1497,6 +2291,8 @@ snapshots:
esutils@2.0.3: {}
+ extend@3.0.2: {}
+
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
@@ -1509,6 +2305,11 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ fetch-blob@3.2.0:
+ dependencies:
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.3.3
+
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -1531,15 +2332,66 @@ snapshots:
flatted@3.4.3: {}
+ formdata-polyfill@4.0.10:
+ dependencies:
+ fetch-blob: 3.2.0
+
fsevents@2.3.3:
optional: true
+ gaxios@7.3.0:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ transitivePeerDependencies:
+ - supports-color
+
+ gcp-metadata@8.1.2:
+ dependencies:
+ gaxios: 7.3.0
+ google-logging-utils: 1.1.3
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
+ google-auth-library@10.9.1:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.3.0
+ gcp-metadata: 8.1.2
+ google-logging-utils: 1.1.3
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ google-logging-utils@1.1.3: {}
+
+ graceful-fs@4.2.11: {}
+
+ http-proxy-agent@7.0.2:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ https-proxy-agent@7.0.6:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
ignore@5.3.2: {}
+ ignore@7.0.5: {}
+
ignore@7.0.6: {}
imurmurhash@0.1.4: {}
@@ -1554,12 +2406,32 @@ snapshots:
joycon@3.1.1: {}
+ json-bigint@1.0.0:
+ dependencies:
+ bignumber.js: 9.3.1
+
json-buffer@3.0.1: {}
+ json-schema-to-ts@3.1.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ ts-algebra: 2.0.0
+
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -1579,6 +2451,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
+ long@5.3.2: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1604,8 +2478,21 @@ snapshots:
natural-compare@1.4.0: {}
+ node-domexception@1.0.0: {}
+
+ node-fetch@3.3.2:
+ dependencies:
+ data-uri-to-buffer: 4.0.1
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+
object-assign@4.1.1: {}
+ openai@6.26.0(ws@8.21.1)(zod@4.4.3):
+ optionalDependencies:
+ ws: 8.21.1
+ zod: 4.4.3
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -1623,6 +2510,13 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ p-retry@4.6.2:
+ dependencies:
+ '@types/retry': 0.12.0
+ retry: 0.13.1
+
+ partial-json@0.1.7: {}
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -1641,9 +2535,11 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
- postcss-load-config@6.0.1:
+ postcss-load-config@6.0.1(yaml@2.9.0):
dependencies:
lilconfig: 3.1.3
+ optionalDependencies:
+ yaml: 2.9.0
prelude-ls@1.2.1: {}
@@ -1653,12 +2549,36 @@ snapshots:
prettier@3.9.6: {}
+ proper-lockfile@4.1.2:
+ dependencies:
+ graceful-fs: 4.2.11
+ retry: 0.12.0
+ signal-exit: 3.0.7
+
+ protobufjs@7.6.5:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.5
+ '@protobufjs/eventemitter': 1.1.1
+ '@protobufjs/fetch': 1.1.1
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.2
+ '@types/node': 22.20.1
+ long: 5.3.2
+
punycode@2.3.1: {}
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
+ retry@0.12.0: {}
+
+ retry@0.13.1: {}
+
rollup@4.62.2:
dependencies:
'@types/estree': 1.0.9
@@ -1690,6 +2610,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.2
fsevents: 2.3.3
+ safe-buffer@5.2.1: {}
+
semver@7.8.5: {}
shebang-command@2.0.0:
@@ -1698,6 +2620,8 @@ snapshots:
shebang-regex@3.0.0: {}
+ signal-exit@3.0.7: {}
+
source-map@0.7.6: {}
sucrase@3.35.1:
@@ -1731,13 +2655,17 @@ snapshots:
tree-kill@1.2.2: {}
+ ts-algebra@2.0.0: {}
+
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-interface-checker@0.1.13: {}
- tsup@8.5.1(typescript@5.9.3):
+ tslib@2.8.1: {}
+
+ tsup@8.5.1(typescript@5.9.3)(yaml@2.9.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.28.1)
cac: 6.7.14
@@ -1748,7 +2676,7 @@ snapshots:
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
- postcss-load-config: 6.0.1
+ postcss-load-config: 6.0.1(yaml@2.9.0)
resolve-from: 5.0.0
rollup: 4.62.2
source-map: 0.7.6
@@ -1768,6 +2696,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
+ typebox@1.1.38: {}
+
typebox@1.3.8: {}
typescript@5.9.3: {}
@@ -1780,10 +2710,22 @@ snapshots:
dependencies:
punycode: 2.3.1
+ web-streams-polyfill@3.3.3: {}
+
which@2.0.2:
dependencies:
isexe: 2.0.0
word-wrap@1.2.5: {}
+ ws@8.21.1: {}
+
+ yaml@2.9.0: {}
+
yocto-queue@0.1.0: {}
+
+ zod-to-json-schema@3.25.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e316230..8c87ea9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,7 +7,9 @@ packages:
minimumReleaseAge: 60
allowBuilds:
+ "@google/genai": true
esbuild: true
+ protobufjs: true
autoInstallPeers: false
overrides:
From 17ce1b27de3a4c3ac5f7e592553cef54c7458b3a Mon Sep 17 00:00:00 2001
From: MemScribe Maintainers
Date: Tue, 28 Jul 2026 16:36:23 +0800
Subject: [PATCH 2/6] refactor: publish a single MemFlywheel package
Signed-off-by: MemScribe Maintainers
---
.github/ISSUE_TEMPLATE/bug_report.md | 2 +-
.github/workflows/e2e.yml | 5 +-
.github/workflows/preview-release.yml | 2 +-
CHANGELOG.md | 3 +
README.md | 25 ++-
README.zh.md | 23 ++-
SUPPORT.md | 14 +-
docs/architecture.md | 14 +-
docs/integrations.md | 27 ++-
docs/release.md | 57 +++----
e2e/agents.mjs | 6 +-
e2e/kind/03-memflywheel-install.sh | 4 +-
e2e/kind/Dockerfile.hermes | 12 +-
e2e/kind/Dockerfile.openclaw | 8 +-
e2e/kind/Dockerfile.pi | 8 +-
e2e/openclaw/chat.mjs | 2 +-
e2e/pi/extension.mjs | 18 +-
examples/hermes/README.md | 2 +-
examples/hermes/plugin-register.mjs | 2 +-
examples/openclaw/plugin.mjs | 2 +-
examples/openclaw/run.mjs | 2 +-
examples/package.json | 2 +-
examples/pi/README.md | 2 +-
examples/pi/extension.mjs | 9 +-
package.json | 10 +-
packages/hermes-plugin/LICENSE | 159 ------------------
packages/hermes-plugin/NOTICE | 11 --
packages/hermes-plugin/README.md | 120 -------------
packages/hermes-plugin/THIRD_PARTY_LICENSES | 33 ----
packages/hermes-plugin/package.json | 37 ----
packages/{adapters => memflywheel}/LICENSE | 0
packages/{adapters => memflywheel}/NOTICE | 0
packages/{adapters => memflywheel}/README.md | 47 +++---
.../THIRD_PARTY_LICENSES | 0
.../bin/install.mjs | 6 +-
.../bridge/worker.mjs | 6 +-
.../guard/__init__.py | 0
.../guard/plugin.yaml | 2 +-
.../openclaw-extension/index.mjs | 0
.../openclaw.plugin.json | 0
.../opencode-plugin/index.js | 0
.../opencode-plugin/index.mjs | 0
.../{adapters => memflywheel}/package.json | 25 ++-
.../pi-extension/index.mjs | 0
.../pi-extension/sync.mjs | 0
.../pi-extension/sync.test.mjs | 0
.../plugin.yaml | 2 +-
.../provider/__init__.py | 14 +-
.../{adapters => memflywheel}/src/adapter.ts | 0
.../src/connect.test.ts | 0
.../src/harness-port.test.ts | 0
.../src/harness-port.ts | 0
.../{adapters => memflywheel}/src/hermes.ts | 0
.../src/host-memflywheel.test.ts | 0
.../src/host-memflywheel.ts | 2 +-
.../{adapters => memflywheel}/src/index.ts | 2 +-
.../src/install.test.ts | 0
.../src/lifecycle.test.ts | 0
.../src/make-adapter.ts | 0
.../src/model-test-support.test.ts | 0
.../src/openclaw-native-model.ts | 0
.../src/openclaw-port.test.ts | 0
.../src/openclaw-port.ts | 0
.../{adapters => memflywheel}/src/openclaw.ts | 0
.../src/opencode-pi-model.ts | 0
.../src/opencode-port.test.ts | 4 +-
.../src/opencode-port.ts | 1 -
.../{adapters => memflywheel}/src/opencode.ts | 0
.../src/pi-port.test.ts | 0
.../{adapters => memflywheel}/src/pi-port.ts | 0
packages/{adapters => memflywheel}/src/pi.ts | 2 +-
.../src/registry.test.ts | 0
.../{adapters => memflywheel}/src/registry.ts | 0
.../src/test-helpers.ts | 0
.../src/verify-doctor.test.ts | 0
.../tests/provider_contract_test.py | 4 +-
.../tsconfig.bundle.json | 0
.../{adapters => memflywheel}/tsconfig.json | 0
pnpm-lock.yaml | 47 +++---
scripts/publish-npm.mjs | 6 +-
80 files changed, 221 insertions(+), 570 deletions(-)
delete mode 100644 packages/hermes-plugin/LICENSE
delete mode 100644 packages/hermes-plugin/NOTICE
delete mode 100644 packages/hermes-plugin/README.md
delete mode 100644 packages/hermes-plugin/THIRD_PARTY_LICENSES
delete mode 100644 packages/hermes-plugin/package.json
rename packages/{adapters => memflywheel}/LICENSE (100%)
rename packages/{adapters => memflywheel}/NOTICE (100%)
rename packages/{adapters => memflywheel}/README.md (87%)
rename packages/{adapters => memflywheel}/THIRD_PARTY_LICENSES (100%)
rename packages/{hermes-plugin => memflywheel}/bin/install.mjs (95%)
mode change 100644 => 100755
rename packages/{hermes-plugin => memflywheel}/bridge/worker.mjs (98%)
rename packages/{hermes-plugin => memflywheel}/guard/__init__.py (100%)
rename packages/{hermes-plugin => memflywheel}/guard/plugin.yaml (90%)
rename packages/{adapters => memflywheel}/openclaw-extension/index.mjs (100%)
rename packages/{adapters => memflywheel}/openclaw.plugin.json (100%)
rename packages/{adapters => memflywheel}/opencode-plugin/index.js (100%)
rename packages/{adapters => memflywheel}/opencode-plugin/index.mjs (100%)
rename packages/{adapters => memflywheel}/package.json (70%)
rename packages/{adapters => memflywheel}/pi-extension/index.mjs (100%)
rename packages/{adapters => memflywheel}/pi-extension/sync.mjs (100%)
rename packages/{adapters => memflywheel}/pi-extension/sync.test.mjs (100%)
rename packages/{hermes-plugin => memflywheel}/plugin.yaml (87%)
rename packages/{hermes-plugin => memflywheel}/provider/__init__.py (97%)
rename packages/{adapters => memflywheel}/src/adapter.ts (100%)
rename packages/{adapters => memflywheel}/src/connect.test.ts (100%)
rename packages/{adapters => memflywheel}/src/harness-port.test.ts (100%)
rename packages/{adapters => memflywheel}/src/harness-port.ts (100%)
rename packages/{adapters => memflywheel}/src/hermes.ts (100%)
rename packages/{adapters => memflywheel}/src/host-memflywheel.test.ts (100%)
rename packages/{adapters => memflywheel}/src/host-memflywheel.ts (99%)
rename packages/{adapters => memflywheel}/src/index.ts (98%)
rename packages/{adapters => memflywheel}/src/install.test.ts (100%)
rename packages/{adapters => memflywheel}/src/lifecycle.test.ts (100%)
rename packages/{adapters => memflywheel}/src/make-adapter.ts (100%)
rename packages/{adapters => memflywheel}/src/model-test-support.test.ts (100%)
rename packages/{adapters => memflywheel}/src/openclaw-native-model.ts (100%)
rename packages/{adapters => memflywheel}/src/openclaw-port.test.ts (100%)
rename packages/{adapters => memflywheel}/src/openclaw-port.ts (100%)
rename packages/{adapters => memflywheel}/src/openclaw.ts (100%)
rename packages/{adapters => memflywheel}/src/opencode-pi-model.ts (100%)
rename packages/{adapters => memflywheel}/src/opencode-port.test.ts (97%)
rename packages/{adapters => memflywheel}/src/opencode-port.ts (99%)
rename packages/{adapters => memflywheel}/src/opencode.ts (100%)
rename packages/{adapters => memflywheel}/src/pi-port.test.ts (100%)
rename packages/{adapters => memflywheel}/src/pi-port.ts (100%)
rename packages/{adapters => memflywheel}/src/pi.ts (94%)
rename packages/{adapters => memflywheel}/src/registry.test.ts (100%)
rename packages/{adapters => memflywheel}/src/registry.ts (100%)
rename packages/{adapters => memflywheel}/src/test-helpers.ts (100%)
rename packages/{adapters => memflywheel}/src/verify-doctor.test.ts (100%)
rename packages/{hermes-plugin => memflywheel}/tests/provider_contract_test.py (98%)
rename packages/{adapters => memflywheel}/tsconfig.bundle.json (100%)
rename packages/{adapters => memflywheel}/tsconfig.json (100%)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 526e93a..ab08138 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -8,7 +8,7 @@ assignees: ""
## Package
-`@iflytekopensource/adapters` / `@iflytekopensource/hermes` / internal workspace
+`@iflytekopensource/memflywheel` / internal workspace
## Environment
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index a98926b..4144ae3 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -43,11 +43,10 @@ jobs:
- name: Build
run: pnpm run build
- - name: Pack memflywheel packages
+ - name: Pack memflywheel package
run: |
mkdir -p e2e/packages
- pnpm --filter @iflytekopensource/adapters pack --pack-destination e2e/packages/
- pnpm --filter @iflytekopensource/hermes pack --pack-destination e2e/packages/
+ pnpm --filter @iflytekopensource/memflywheel pack --pack-destination e2e/packages/
ls -la e2e/packages/
- name: Install kind
diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml
index 682098b..2db1d8d 100644
--- a/.github/workflows/preview-release.yml
+++ b/.github/workflows/preview-release.yml
@@ -39,4 +39,4 @@ jobs:
run: pnpm run build
- name: Publish preview
- run: pnpx pkg-pr-new publish packages/adapters packages/hermes-plugin
+ run: pnpx pkg-pr-new publish packages/memflywheel
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2541df8..6abe4a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,9 @@ for published packages.
### Changed
+- Consolidated the former `@iflytekopensource/adapters` and
+ `@iflytekopensource/hermes` distributions into the single public
+ `@iflytekopensource/memflywheel` package for all four hosts.
- Unified extraction, dream, and skill evolution on Pi Agent Core with
host-resolved `pi-ai` model bindings.
- Reused each host's active model, endpoint, credentials, headers, protocol, and
diff --git a/README.md b/README.md
index b5a6591..7c7da4d 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,8 @@
-
-
+
+
@@ -35,7 +35,7 @@ skills.
Post-run learning Turn-end extraction and dream consolidation keep memory moving. |
- Harness-native Pi, Hermes, OpenCode, and OpenClaw are supported through npm packages. |
+ Harness-native Pi, Hermes, OpenCode, and OpenClaw are supported through one npm package. |
@@ -77,13 +77,13 @@ MemFlywheel
Pi:
```sh
-pi install npm:@iflytekopensource/adapters
+pi install npm:@iflytekopensource/memflywheel
```
Hermes:
```sh
-npm install -g @iflytekopensource/hermes
+npm install -g @iflytekopensource/memflywheel
memflywheel-hermes-install
hermes config set memory.provider memflywheel
```
@@ -91,14 +91,14 @@ hermes config set memory.provider memflywheel
OpenCode:
```sh
-opencode plugin @iflytekopensource/adapters --global
+opencode plugin @iflytekopensource/memflywheel --global
opencode run --dir /path/to/project "your task"
```
OpenClaw:
```sh
-openclaw plugins install npm:@iflytekopensource/adapters
+openclaw plugins install npm:@iflytekopensource/memflywheel
openclaw config set plugins.slots.memory memflywheel
openclaw config set plugins.entries.memflywheel.hooks.allowConversationAccess true
openclaw config set plugins.entries.memflywheel.hooks.allowPromptInjection true
@@ -124,15 +124,14 @@ export MEMFLYWHEEL_EMBEDDING_MODEL="text-embedding-3-small"
Host setup, embedding pre-recall, verification, and troubleshooting live in
[`docs/integrations.md`](docs/integrations.md).
-## Install Packages
+## Install Package
-| Package | Role |
-| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
-| [`@iflytekopensource/adapters`](https://www.npmjs.com/package/@iflytekopensource/adapters) | Pi, OpenCode, OpenClaw, and the shared host-adapter runtime used by Hermes |
-| [`@iflytekopensource/hermes`](https://www.npmjs.com/package/@iflytekopensource/hermes) | Hermes MemoryProvider installer and skill mirror |
+| Package | Role |
+| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
+| [`@iflytekopensource/memflywheel`](https://www.npmjs.com/package/@iflytekopensource/memflywheel) | Pi, Hermes, OpenCode, and OpenClaw integrations, including the Hermes MemoryProvider installer and skill mirror |
Internal workspace packages keep the code split by responsibility; users install
-only the host package they need.
+the same public package for every supported host.
## Evaluation
diff --git a/README.zh.md b/README.zh.md
index 925fc43..6419e41 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,8 +14,8 @@
-
-
+
+
@@ -33,7 +33,7 @@ MemFlywheel 给 Agent Harness 增加一层文件原生记忆飞轮:执行前
执行后学习 turn-end 提取和 dream 整理让记忆持续流动。 |
- 宿主原生 Pi、Hermes、OpenCode 和 OpenClaw 均通过 npm 包接入。 |
+ 宿主原生 Pi、Hermes、OpenCode 和 OpenClaw 均通过同一个 npm 包接入。 |
@@ -73,13 +73,13 @@ MemFlywheel
Pi:
```sh
-pi install npm:@iflytekopensource/adapters
+pi install npm:@iflytekopensource/memflywheel
```
Hermes:
```sh
-npm install -g @iflytekopensource/hermes
+npm install -g @iflytekopensource/memflywheel
memflywheel-hermes-install
hermes config set memory.provider memflywheel
```
@@ -87,14 +87,14 @@ hermes config set memory.provider memflywheel
OpenCode:
```sh
-opencode plugin @iflytekopensource/adapters --global
+opencode plugin @iflytekopensource/memflywheel --global
opencode run --dir /path/to/project "你的任务"
```
OpenClaw:
```sh
-openclaw plugins install npm:@iflytekopensource/adapters
+openclaw plugins install npm:@iflytekopensource/memflywheel
openclaw config set plugins.slots.memory memflywheel
openclaw config set plugins.entries.memflywheel.hooks.allowConversationAccess true
openclaw config set plugins.entries.memflywheel.hooks.allowPromptInjection true
@@ -117,12 +117,11 @@ export MEMFLYWHEEL_EMBEDDING_MODEL="text-embedding-3-small"
## 安装包
-| Package | 作用 |
-| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
-| [`@iflytekopensource/adapters`](https://www.npmjs.com/package/@iflytekopensource/adapters) | Pi、OpenCode、OpenClaw,以及 Hermes bridge 复用的宿主适配运行层 |
-| [`@iflytekopensource/hermes`](https://www.npmjs.com/package/@iflytekopensource/hermes) | Hermes MemoryProvider 安装器和 skill 镜像 |
+| Package | 作用 |
+| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
+| [`@iflytekopensource/memflywheel`](https://www.npmjs.com/package/@iflytekopensource/memflywheel) | Pi、Hermes、OpenCode 和 OpenClaw 集成,包含 Hermes MemoryProvider 安装器和 skill 镜像 |
-内部 workspace 包按职责拆代码;普通用户只安装自己宿主需要的包。
+内部 workspace 包按职责拆代码;四个宿主的用户都安装同一个公开包。
## 评测
diff --git a/SUPPORT.md b/SUPPORT.md
index 8363555..40677e8 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -6,13 +6,13 @@ https://github.com/iflytek/memflywheel/issues
Before filing an issue, please include:
-| Field | Example |
-| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
-| Package | `@memflywheel/core`, `@memflywheel/embeddings`, `@memflywheel/sdk`, `@memflywheel/skills`, or `@iflytekopensource/adapters` |
-| Runtime | Node.js version and operating system |
-| Command | The exact command or API call that failed |
-| Expected result | What you expected to happen |
-| Actual result | What happened instead |
+| Field | Example |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| Package | `@memflywheel/core`, `@memflywheel/embeddings`, `@memflywheel/sdk`, `@memflywheel/skills`, or `@iflytekopensource/memflywheel` |
+| Runtime | Node.js version and operating system |
+| Command | The exact command or API call that failed |
+| Expected result | What you expected to happen |
+| Actual result | What happened instead |
Do not include API keys, tokens, private memory files, or private filesystem
paths in public issues.
diff --git a/docs/architecture.md b/docs/architecture.md
index fe06744..7351426 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -5,10 +5,10 @@ MemFlywheel is a file-backed long-term memory kernel. It has four moving parts
atomic writes, audit) that every write path shares.
The core (`@memflywheel/core`) is pure filesystem logic plus injected ports. It never owns
-model transport, provider auth, or provider wire shapes. The two generative steps
-(extraction, dream) reach the model only through injected function contracts; optional
+model transport, provider auth, or provider wire shapes. The generative steps
+(extraction, dream, and skill evolution) reach the model only through injected function contracts; optional
index-layer retrieval consumes a host-supplied embedding provider. Hosts wire those
-contracts and the turn lifecycle through `@memflywheel/sdk` and `@iflytekopensource/adapters`.
+contracts and the turn lifecycle through `@memflywheel/sdk` and `@iflytekopensource/memflywheel`.
## Memory root and layout
@@ -185,9 +185,9 @@ reusable skill route for future turns.
## Package boundaries
-Only `@iflytekopensource/adapters` and `@iflytekopensource/hermes` are public npm packages.
-The layers below remain private workspace packages; release builds bundle the
-runtime pieces into the host-facing packages.
+`@iflytekopensource/memflywheel` is the only public npm package. The layers below
+remain private workspace packages; the release build bundles their runtime pieces,
+all four host entrypoints, and the Hermes installer into that package.
```
@memflywheel/core filesystem only, no LLM, no host coupling
@@ -197,7 +197,7 @@ runtime pieces into the host-facing packages.
@memflywheel/sdk wires injection points + turn lifecycle
▲
│
-@iflytekopensource/adapters per-host lifecycle mapping
+@iflytekopensource/memflywheel host mappings + native model bindings + installers
```
See [`integrations.md`](integrations.md) for the SDK and host adapter surfaces.
diff --git a/docs/integrations.md b/docs/integrations.md
index 1dcb7bc..fa86bd9 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -113,13 +113,13 @@ declares its extension entry under the `pi` key in `package.json`.
}
```
-Install the published adapter package into Pi:
+Install the published MemFlywheel package into Pi:
```sh
-pi install npm:@iflytekopensource/adapters
+pi install npm:@iflytekopensource/memflywheel
```
-Pi then loads `packages/adapters/pi-extension/index.mjs` from the npm package.
+Pi then loads `packages/memflywheel/pi-extension/index.mjs` from the npm package.
That extension maps Pi lifecycle and tool-calling model access into
`HostHarnessPort`, then builds the MemFlywheel runtime:
@@ -130,7 +130,7 @@ Pi package
v
pi-extension/index.mjs
|
- | createPiHarnessPort(pi, { completeSimple })
+ | createPiHarnessPort(pi, { streamSimple })
v
createMemFlywheelHarnessRuntime({ port })
|
@@ -143,14 +143,13 @@ createMemFlywheelHarnessRuntime({ port })
Source checkout smoke test:
```sh
-pnpm -r build
-USE_FAKE=1 node examples/pi/run.mjs
+pnpm --dir examples smoke
```
-`@earendil-works/pi-ai` is a runtime dependency of the adapter package. The Pi
-extension imports `completeSimple` from `@earendil-works/pi-ai/compat`, while the
+`@earendil-works/pi-ai` is a runtime dependency of the package. The Pi
+extension imports `streamSimple` from `@earendil-works/pi-ai/compat`, while the
OpenCode bridge uses pi-ai's provider APIs. The dependency remains external to
-the adapter bundle and is installed alongside it.
+the runtime bundle and is installed alongside it.
## Hermes Integration
@@ -159,7 +158,7 @@ npm, run the installer once, then select it through Hermes' native memory
config.
```sh
-npm install -g @iflytekopensource/hermes
+npm install -g @iflytekopensource/memflywheel
memflywheel-hermes-install
hermes config set memory.provider memflywheel
hermes memory status
@@ -196,8 +195,8 @@ Source checkout uses the same installer path as npm; the only difference is that
the package is executed from the workspace instead of a global npm install:
```sh
-pnpm --filter @iflytekopensource/hermes run build
-pnpm --filter @iflytekopensource/hermes run install:local
+pnpm --filter @iflytekopensource/memflywheel run build
+pnpm --filter @iflytekopensource/memflywheel run install:local
hermes config set memory.provider memflywheel
```
@@ -247,7 +246,7 @@ Expected behavior after a real session:
## OpenCode Integration
```sh
-opencode plugin @iflytekopensource/adapters --global
+opencode plugin @iflytekopensource/memflywheel --global
opencode run --dir /path/to/project "your task"
```
@@ -300,7 +299,7 @@ your test harness' explicit permission override.
## OpenClaw Integration
```sh
-openclaw plugins install npm:@iflytekopensource/adapters
+openclaw plugins install npm:@iflytekopensource/memflywheel
openclaw config set plugins.slots.memory memflywheel
openclaw config set plugins.entries.memflywheel.hooks.allowConversationAccess true
openclaw config set plugins.entries.memflywheel.hooks.allowPromptInjection true
diff --git a/docs/release.md b/docs/release.md
index 9994f54..cd63fb8 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -13,9 +13,9 @@ MemFlywheel publishes npm packages from GitHub Actions.
| Preview package | Push a `v*` git tag | `.github/workflows/preview-release.yml` | `pkg-pr-new` preview output |
| Pull request validation | Pull request to `main` | `.github/workflows/ci.yml` | Build, test, pack dry run |
-The root `memflywheel` package is private and is not published. Only the two
-host-facing packages are published; internal workspace packages stay private and
-are bundled into the host packages when needed.
+The root `memflywheel` package is private and is not published. The single
+host-facing package is published; internal workspace packages stay private and
+are bundled into it.
Pull requests do not publish to npmjs. A PR only proves that the packages can be
built, tested, and packed. The npmjs publish happens only after a maintainer
@@ -24,10 +24,9 @@ GitHub Action.
## Published packages
-| Package | Purpose |
-| ----------------------------- | -------------------------------------------------------------------------- |
-| `@iflytekopensource/adapters` | Pi, OpenCode, OpenClaw, and the shared host-adapter runtime used by Hermes |
-| `@iflytekopensource/hermes` | Hermes MemoryProvider installer and skill mirror |
+| Package | Purpose |
+| -------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `@iflytekopensource/memflywheel` | Pi, Hermes, OpenCode, and OpenClaw integrations, including the Hermes MemoryProvider installer and skill mirror |
Internal workspace packages:
@@ -38,16 +37,6 @@ Internal workspace packages:
| `@memflywheel/sdk` | Host lifecycle SDK and memory/dream/skill loops |
| `@memflywheel/skills` | Learned-skill package store, validation, finalize, rollback, and recall |
-Publish packages in dependency order:
-
-```text
-@iflytekopensource/adapters
-@iflytekopensource/hermes
-```
-
-`@iflytekopensource/hermes` depends on `@iflytekopensource/adapters`, so it cannot be
-published or installed first on a clean npm registry.
-
## Versioning rule
Use a single repository version for the release train.
@@ -77,7 +66,7 @@ separate release-infra PR.
## npm dist-tag rule
-`pnpm run publish:npm` publishes both public packages with the npm `latest`
+`pnpm run publish:npm` publishes the public package with the npm `latest`
dist-tag by default. Pass a tag explicitly for prerelease channels:
```sh
@@ -108,15 +97,15 @@ pnpm run pack:dry-run
Before tagging, confirm:
-| Check | Expected result |
-| ---------------- | ------------------------------------------------------------------------------------------ |
-| Package versions | Root and all `packages/*` versions match |
-| Package metadata | Repository URLs point to `iflytek/memflywheel` |
-| Package contents | Dry-run output includes only `@iflytekopensource/adapters` and `@iflytekopensource/hermes` |
-| Secrets | No credentials, private paths, or local-only files are included |
-| Notices | `NOTICE` and `THIRD_PARTY_LICENSES` are current |
-| Publish order | `pnpm run publish:npm -- --dry-run` publishes adapters, then Hermes |
-| CI | GitHub PR checks pass before merge |
+| Check | Expected result |
+| ---------------- | ------------------------------------------------------------------------ |
+| Package versions | Root and all `packages/*` versions match |
+| Package metadata | Repository URLs point to `iflytek/memflywheel` |
+| Package contents | Dry-run output includes only `@iflytekopensource/memflywheel` |
+| Host entries | Pi, Hermes, OpenCode, and OpenClaw files are present in the same tarball |
+| Secrets | No credentials, private paths, or local-only files are included |
+| Notices | `NOTICE` and `THIRD_PARTY_LICENSES` are current |
+| CI | GitHub PR checks pass before merge |
## Release steps
@@ -158,8 +147,7 @@ repository URL when needed.
After the workflow finishes:
```sh
-npm view @iflytekopensource/adapters version
-npm view @iflytekopensource/hermes version
+npm view @iflytekopensource/memflywheel version
```
Confirm the npm versions match the tag and that GitHub Actions completed
@@ -167,11 +155,10 @@ successfully.
## Failure handling
-| Failure | Action |
-| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
-| CI fails before tagging | Fix in a normal PR; do not tag |
-| Release workflow fails before any package publishes | Fix the workflow or token, then rerun the failed workflow |
-| Some packages publish and others fail | Do not delete published versions; fix the cause and publish the missing packages with the same version if npm allows it |
-| Wrong package content is published | Deprecate the bad npm version and publish a corrected patch version |
+| Failure | Action |
+| ------------------------------- | ------------------------------------------------------------------- |
+| CI fails before tagging | Fix in a normal PR; do not tag |
+| Release workflow fails | Fix the workflow or token, then rerun the failed workflow |
+| Wrong package content published | Deprecate the bad npm version and publish a corrected patch version |
Never rewrite public release tags after a tag has triggered a publish workflow.
diff --git a/e2e/agents.mjs b/e2e/agents.mjs
index c05bc5c..7ca9e17 100644
--- a/e2e/agents.mjs
+++ b/e2e/agents.mjs
@@ -154,15 +154,15 @@ function openclawVerifySetup() {
check("node binary available", false, e.message?.slice(0, 100));
}
try {
- const adapterList = kubectlExec(
+ const packageList = kubectlExec(
OPENCLAW_NS,
OPENCLAW_POD,
"ls",
"/usr/local/lib/node_modules/@iflytekopensource/",
);
- check("adapters package installed", adapterList.includes("adapters"));
+ check("memflywheel package installed", packageList.includes("memflywheel"));
} catch {
- check("adapters package installed", false, "node_modules not found");
+ check("memflywheel package installed", false, "node_modules not found");
}
try {
const scripts = kubectlExec(OPENCLAW_NS, OPENCLAW_POD, "ls", "/e2e/openclaw/");
diff --git a/e2e/kind/03-memflywheel-install.sh b/e2e/kind/03-memflywheel-install.sh
index b0a6bd6..633cc41 100755
--- a/e2e/kind/03-memflywheel-install.sh
+++ b/e2e/kind/03-memflywheel-install.sh
@@ -28,7 +28,7 @@ if [ -f "$CONFIG" ] && ! grep -q "disabled_toolsets" "$CONFIG"; then
echo "[memflywheel] Added agent.disabled_toolsets to config.yaml"
fi
-# Run the install script (installed globally from @iflytekopensource/hermes)
+# Run the install script (installed globally from @iflytekopensource/memflywheel)
memflywheel-hermes-install || {
echo "[memflywheel] ERROR: memflywheel-hermes-install failed"
exit 1
@@ -36,7 +36,7 @@ memflywheel-hermes-install || {
# Ensure globally-installed Node modules are resolvable from the plugin dir.
# Node.js ESM import does NOT respect NODE_PATH — we need a real node_modules
-# directory/symlink for the plugin to resolve @iflytekopensource/adapters.
+# directory/symlink for the plugin to resolve @iflytekopensource/memflywheel.
PLUGIN_DIR="${HERMES_HOME}/plugins/memflywheel"
if [ -d "$PLUGIN_DIR" ]; then
ln -sf /usr/local/lib/node_modules "${PLUGIN_DIR}/node_modules" 2>/dev/null || true
diff --git a/e2e/kind/Dockerfile.hermes b/e2e/kind/Dockerfile.hermes
index eacc3a0..b18b8f4 100644
--- a/e2e/kind/Dockerfile.hermes
+++ b/e2e/kind/Dockerfile.hermes
@@ -2,20 +2,18 @@
#
# Build context: repo root.
# Expected files in build context:
-# e2e/packages/iflytekopensource-adapters-*.tgz
-# e2e/packages/iflytekopensource-hermes-*.tgz
+# e2e/packages/iflytekopensource-memflywheel-*.tgz
# e2e/kind/03-memflywheel-install.sh
FROM nousresearch/hermes-agent:latest
USER root
-# Copy memflywheel packages
-COPY e2e/packages/iflytekopensource-adapters-*.tgz /tmp/adapters.tgz
-COPY e2e/packages/iflytekopensource-hermes-*.tgz /tmp/hermes.tgz
+# Copy the unified memflywheel package
+COPY e2e/packages/iflytekopensource-memflywheel-*.tgz /tmp/memflywheel.tgz
-# Install the packages globally so memflywheel-hermes-install can resolve them
-RUN cd /tmp && npm install --global ./adapters.tgz ./hermes.tgz
+# Install it globally so memflywheel-hermes-install is available
+RUN npm install --global /tmp/memflywheel.tgz
# Make globally-installed packages resolvable from any working directory
ENV NODE_PATH=/usr/local/lib/node_modules
diff --git a/e2e/kind/Dockerfile.openclaw b/e2e/kind/Dockerfile.openclaw
index 8c971a0..ae951d0 100644
--- a/e2e/kind/Dockerfile.openclaw
+++ b/e2e/kind/Dockerfile.openclaw
@@ -2,7 +2,7 @@
#
# Build context: repo root.
# Expected files in build context:
-# e2e/packages/iflytekopensource-adapters-*.tgz
+# e2e/packages/iflytekopensource-memflywheel-*.tgz
# e2e/openclaw/chat.mjs
# e2e/openclaw/extract.mjs
@@ -10,9 +10,9 @@ FROM ghcr.io/openclaw/openclaw:2026.3.23
USER root
-# Copy memflywheel adapter package
-COPY e2e/packages/iflytekopensource-adapters-*.tgz /tmp/adapters.tgz
-RUN npm install -g /tmp/adapters.tgz
+# Copy the unified memflywheel package
+COPY e2e/packages/iflytekopensource-memflywheel-*.tgz /tmp/memflywheel.tgz
+RUN npm install -g /tmp/memflywheel.tgz
# Make globally-installed packages resolvable from any working directory
ENV NODE_PATH=/usr/local/lib/node_modules
diff --git a/e2e/kind/Dockerfile.pi b/e2e/kind/Dockerfile.pi
index 70b95e8..a8a8cf8 100644
--- a/e2e/kind/Dockerfile.pi
+++ b/e2e/kind/Dockerfile.pi
@@ -2,7 +2,7 @@
#
# Build context: repo root.
# Expected files in build context:
-# e2e/packages/iflytekopensource-adapters-*.tgz
+# e2e/packages/iflytekopensource-memflywheel-*.tgz
# e2e/pi/extension.mjs
# e2e/pi/settings.json
@@ -15,9 +15,9 @@ RUN apt-get update \
# Install Pi coding agent
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
-# Copy memflywheel adapter package
-COPY e2e/packages/iflytekopensource-adapters-*.tgz /tmp/adapters.tgz
-RUN npm install -g /tmp/adapters.tgz
+# Copy the unified memflywheel package
+COPY e2e/packages/iflytekopensource-memflywheel-*.tgz /tmp/memflywheel.tgz
+RUN npm install -g /tmp/memflywheel.tgz
# Make globally-installed packages resolvable from any working directory
ENV NODE_PATH=/usr/local/lib/node_modules
diff --git a/e2e/openclaw/chat.mjs b/e2e/openclaw/chat.mjs
index c09a24e..ed4a9d3 100644
--- a/e2e/openclaw/chat.mjs
+++ b/e2e/openclaw/chat.mjs
@@ -11,7 +11,7 @@
* Usage: node /e2e/openclaw/chat.mjs "prompt text"
*/
-import { createMemFlywheelHarnessRuntime, openclawAdapter } from "@iflytekopensource/adapters";
+import { createMemFlywheelHarnessRuntime, openclawAdapter } from "@iflytekopensource/memflywheel";
const prompt = process.argv[2];
if (!prompt) {
diff --git a/e2e/pi/extension.mjs b/e2e/pi/extension.mjs
index f02b39a..6e0bfda 100644
--- a/e2e/pi/extension.mjs
+++ b/e2e/pi/extension.mjs
@@ -6,22 +6,26 @@
* `pi --print` waits for extraction before exiting.
*/
-import { completeSimple } from "@earendil-works/pi-ai/compat";
-import { createMemFlywheelHarnessRuntime, createPiHarnessPort } from "@iflytekopensource/adapters";
+import { streamSimple } from "@earendil-works/pi-ai/compat";
+import {
+ createMemFlywheelHarnessRuntime,
+ createPiHarnessPort,
+} from "@iflytekopensource/memflywheel";
/** @param {any} pi - the Pi ExtensionAPI */
export default function memFlywheelExtension(pi) {
const pendingPromises = new Set();
- // Wrap completeSimple to track extraction model calls
- const trackedCompleteSimple = (model, context, options) => {
- const promise = completeSimple(model, context, options);
+ // Wrap streamSimple to track extraction model calls
+ const trackedStreamSimple = (model, context, options) => {
+ const stream = streamSimple(model, context, options);
+ const promise = stream.result();
pendingPromises.add(promise);
promise.finally(() => pendingPromises.delete(promise));
- return promise;
+ return stream;
};
- const port = createPiHarnessPort(pi, { completeSimple: trackedCompleteSimple });
+ const port = createPiHarnessPort(pi, { streamSimple: trackedStreamSimple });
const runtime = createMemFlywheelHarnessRuntime({ port });
const originalDispose = runtime.dispose;
diff --git a/examples/hermes/README.md b/examples/hermes/README.md
index 04569d9..afded7a 100644
--- a/examples/hermes/README.md
+++ b/examples/hermes/README.md
@@ -2,4 +2,4 @@
The published Hermes MemoryProvider keeps model routing and credentials inside Hermes. Its Python bridge converts one Pi context request into `agent.auxiliary_client.call_llm`, returns one native Pi assistant message, and leaves all tool iteration to Pi Agent Core.
-`plugin-register.mjs` documents the host registration boundary; production installation uses `@iflytekopensource/hermes`.
+`plugin-register.mjs` documents the host registration boundary; production installation uses `@iflytekopensource/memflywheel`.
diff --git a/examples/hermes/plugin-register.mjs b/examples/hermes/plugin-register.mjs
index 32237f1..a7ecc2e 100644
--- a/examples/hermes/plugin-register.mjs
+++ b/examples/hermes/plugin-register.mjs
@@ -10,7 +10,7 @@
* the host's active model transport, over the single Pi Agent Core runner.
*/
-import { createMemFlywheelHarnessRuntime, hermesAdapter } from "@iflytekopensource/adapters";
+import { createMemFlywheelHarnessRuntime, hermesAdapter } from "@iflytekopensource/memflywheel";
/** @param {any} ctx - the Hermes PluginContext */
export function register(ctx) {
diff --git a/examples/openclaw/plugin.mjs b/examples/openclaw/plugin.mjs
index ff99124..e127fe0 100644
--- a/examples/openclaw/plugin.mjs
+++ b/examples/openclaw/plugin.mjs
@@ -11,7 +11,7 @@
* - binds `openclawAdapter` to OpenClaw's hooks.
*/
-import { createMemFlywheelHarnessRuntime, openclawAdapter } from "@iflytekopensource/adapters";
+import { createMemFlywheelHarnessRuntime, openclawAdapter } from "@iflytekopensource/memflywheel";
const plugin = {
id: "memflywheel",
diff --git a/examples/openclaw/run.mjs b/examples/openclaw/run.mjs
index c625477..5a5348d 100644
--- a/examples/openclaw/run.mjs
+++ b/examples/openclaw/run.mjs
@@ -15,7 +15,7 @@ import {
createMemFlywheelHarnessRuntime,
openclawAdapter,
connect,
-} from "@iflytekopensource/adapters";
+} from "@iflytekopensource/memflywheel";
import { transcript } from "../shared/transcript.mjs";
function createMockOpenClawHost() {
diff --git a/examples/package.json b/examples/package.json
index ef2868e..4e4f9dc 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -9,7 +9,7 @@
},
"dependencies": {
"@memflywheel/core": "workspace:*",
- "@iflytekopensource/adapters": "workspace:*",
+ "@iflytekopensource/memflywheel": "workspace:*",
"@memflywheel/skills": "workspace:*",
"@memflywheel/sdk": "workspace:*"
}
diff --git a/examples/pi/README.md b/examples/pi/README.md
index e0f60fb..ef0491a 100644
--- a/examples/pi/README.md
+++ b/examples/pi/README.md
@@ -3,7 +3,7 @@
Install the published Pi package:
```bash
-pi install npm:@iflytekopensource/adapters
+pi install npm:@iflytekopensource/memflywheel
```
The extension passes Pi's active `ctx.model`, native `streamSimple`, host auth, thinking level, and isolated background session id into the shared Pi Agent Core runner. Changing the model in Pi changes the memory agent model on the next run; no `MEMFLYWHEEL_LLM_*` variables are read.
diff --git a/examples/pi/extension.mjs b/examples/pi/extension.mjs
index 87d0ab1..7e3ce34 100644
--- a/examples/pi/extension.mjs
+++ b/examples/pi/extension.mjs
@@ -7,12 +7,15 @@
* events: context / agent_end / session_shutdown / tool_call / tool_result.
*/
-import { completeSimple } from "@earendil-works/pi-ai/compat";
-import { createMemFlywheelHarnessRuntime, createPiHarnessPort } from "@iflytekopensource/adapters";
+import { streamSimple } from "@earendil-works/pi-ai/compat";
+import {
+ createMemFlywheelHarnessRuntime,
+ createPiHarnessPort,
+} from "@iflytekopensource/memflywheel";
/** @param {any} pi - the Pi ExtensionAPI */
export default function memFlywheelExtension(pi) {
- const port = createPiHarnessPort(pi, { completeSimple });
+ const port = createPiHarnessPort(pi, { streamSimple });
const runtime = createMemFlywheelHarnessRuntime({ port });
// Pi disposes extensions on shutdown; return the disposer when supported.
diff --git a/package.json b/package.json
index 270bb06..dc72414 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"format": "prettier --write \"**/*.{ts,mts,mjs,json,md,yml,yaml}\" --ignore-path .prettierignore",
"format:check": "prettier --check \"**/*.{ts,mts,mjs,json,md,yml,yaml}\" --ignore-path .prettierignore",
"ci": "pnpm run clean && pnpm run lint && pnpm run format:check && pnpm run build && pnpm run test && pnpm run pack:dry-run",
- "pack:dry-run": "pnpm --filter @iflytekopensource/adapters run build && pnpm --filter @iflytekopensource/hermes run build && pnpm --filter @iflytekopensource/adapters pack --dry-run --json && pnpm --filter @iflytekopensource/hermes pack --dry-run --json",
+ "pack:dry-run": "pnpm --filter @iflytekopensource/memflywheel run build && pnpm --filter @iflytekopensource/memflywheel pack --dry-run --json",
"publish:npm": "node scripts/publish-npm.mjs"
},
"devDependencies": {
@@ -48,5 +48,11 @@
"prettier": "^3.9.6",
"typebox": "^1.3.8",
"typescript": "^5.5.4"
- }
+ },
+ "main": "eslint.config.js",
+ "directories": {
+ "doc": "docs",
+ "example": "examples"
+ },
+ "author": ""
}
diff --git a/packages/hermes-plugin/LICENSE b/packages/hermes-plugin/LICENSE
deleted file mode 100644
index 9e529b3..0000000
--- a/packages/hermes-plugin/LICENSE
+++ /dev/null
@@ -1,159 +0,0 @@
-Apache License
-Version 2.0, January 2004
-http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-"License" shall mean the terms and conditions for use, reproduction, and
-distribution as defined by Sections 1 through 9 of this document.
-
-"Licensor" shall mean the copyright owner or entity authorized by the copyright
-owner that is granting the License.
-
-"Legal Entity" shall mean the union of the acting entity and all other entities
-that control, are controlled by, or are under common control with that entity.
-For the purposes of this definition, "control" means (i) the power, direct or
-indirect, to cause the direction or management of such entity, whether by
-contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
-outstanding shares, or (iii) beneficial ownership of such entity.
-
-"You" (or "Your") shall mean an individual or Legal Entity exercising
-permissions granted by this License.
-
-"Source" form shall mean the preferred form for making modifications, including
-but not limited to software source code, documentation source, and configuration
-files.
-
-"Object" form shall mean any form resulting from mechanical transformation or
-translation of a Source form, including but not limited to compiled object code,
-generated documentation, and conversions to other media types.
-
-"Work" shall mean the work of authorship, whether in Source or Object form,
-made available under the License, as indicated by a copyright notice that is
-included in or attached to the work.
-
-"Derivative Works" shall mean any work, whether in Source or Object form, that
-is based on (or derived from) the Work and for which the editorial revisions,
-annotations, elaborations, or other modifications represent, as a whole, an
-original work of authorship. For the purposes of this License, Derivative Works
-shall not include works that remain separable from, or merely link (or bind by
-name) to the interfaces of, the Work and Derivative Works thereof.
-
-"Contribution" shall mean any work of authorship, including the original
-version of the Work and any modifications or additions to that Work or
-Derivative Works thereof, that is intentionally submitted to Licensor for
-inclusion in the Work by the copyright owner or by an individual or Legal Entity
-authorized to submit on behalf of the copyright owner. For the purposes of this
-definition, "submitted" means any form of electronic, verbal, or written
-communication sent to the Licensor or its representatives, including but not
-limited to communication on electronic mailing lists, source code control
-systems, and issue tracking systems that are managed by, or on behalf of, the
-Licensor for the purpose of discussing and improving the Work, but excluding
-communication that is conspicuously marked or otherwise designated in writing
-by the copyright owner as "Not a Contribution."
-
-"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
-of whom a Contribution has been received by Licensor and subsequently
-incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of this
-License, each Contributor hereby grants to You a perpetual, worldwide,
-non-exclusive, no-charge, royalty-free, irrevocable copyright license to
-reproduce, prepare Derivative Works of, publicly display, publicly perform,
-sublicense, and distribute the Work and such Derivative Works in Source or
-Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of this License,
-each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
-no-charge, royalty-free, irrevocable patent license to make, have made, use,
-offer to sell, sell, import, and otherwise transfer the Work, where such license
-applies only to those patent claims licensable by such Contributor that are
-necessarily infringed by their Contribution(s) alone or by combination of their
-Contribution(s) with the Work to which such Contribution(s) was submitted. If
-You institute patent litigation against any entity (including a cross-claim or
-counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated
-within the Work constitutes direct or contributory patent infringement, then
-any patent licenses granted to You under this License for that Work shall
-terminate as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the Work or
-Derivative Works thereof in any medium, with or without modifications, and in
-Source or Object form, provided that You meet the following conditions:
-
-(a) You must give any other recipients of the Work or Derivative Works a copy
-of this License; and
-
-(b) You must cause any modified files to carry prominent notices stating that
-You changed the files; and
-
-(c) You must retain, in the Source form of any Derivative Works that You
-distribute, all copyright, patent, trademark, and attribution notices from the
-Source form of the Work, excluding those notices that do not pertain to any
-part of the Derivative Works; and
-
-(d) If the Work includes a "NOTICE" text file as part of its distribution, then
-any Derivative Works that You distribute must include a readable copy of the
-attribution notices contained within such NOTICE file, excluding those notices
-that do not pertain to any part of the Derivative Works, in at least one of the
-following places: within a NOTICE text file distributed as part of the
-Derivative Works; within the Source form or documentation, if provided along
-with the Derivative Works; or, within a display generated by the Derivative
-Works, if and wherever such third-party notices normally appear. The contents
-of the NOTICE file are for informational purposes only and do not modify the
-License. You may add Your own attribution notices within Derivative Works that
-You distribute, alongside or as an addendum to the NOTICE text from the Work,
-provided that such additional attribution notices cannot be construed as
-modifying the License.
-
-You may add Your own copyright statement to Your modifications and may provide
-additional or different license terms and conditions for use, reproduction, or
-distribution of Your modifications, or for any such Derivative Works as a
-whole, provided Your use, reproduction, and distribution of the Work otherwise
-complies with the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise, any
-Contribution intentionally submitted for inclusion in the Work by You to the
-Licensor shall be under the terms and conditions of this License, without any
-additional terms or conditions. Notwithstanding the above, nothing herein shall
-supersede or modify the terms of any separate license agreement you may have
-executed with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade names,
-trademarks, service marks, or product names of the Licensor, except as required
-for reasonable and customary use in describing the origin of the Work and
-reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
-writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
-CONDITIONS OF ANY KIND, either express or implied, including, without
-limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT,
-MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely
-responsible for determining the appropriateness of using or redistributing the
-Work and assume any risks associated with Your exercise of permissions under
-this License.
-
-8. Limitation of Liability. In no event and under no legal theory, whether in
-tort (including negligence), contract, or otherwise, unless required by
-applicable law (such as deliberate and grossly negligent acts) or agreed to in
-writing, shall any Contributor be liable to You for damages, including any
-direct, indirect, special, incidental, or consequential damages of any character
-arising as a result of this License or out of the use or inability to use the
-Work (including but not limited to damages for loss of goodwill, work stoppage,
-computer failure or malfunction, or any and all other commercial damages or
-losses), even if such Contributor has been advised of the possibility of such
-damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing the Work or
-Derivative Works thereof, You may choose to offer, and charge a fee for,
-acceptance of support, warranty, indemnity, or other liability obligations
-and/or rights consistent with this License. However, in accepting such
-obligations, You may act only on Your own behalf and on Your sole
-responsibility, not on behalf of any other Contributor, and only if You agree
-to indemnify, defend, and hold each Contributor harmless for any liability
-incurred by, or claims asserted against, such Contributor by reason of your
-accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
diff --git a/packages/hermes-plugin/NOTICE b/packages/hermes-plugin/NOTICE
deleted file mode 100644
index c590cea..0000000
--- a/packages/hermes-plugin/NOTICE
+++ /dev/null
@@ -1,11 +0,0 @@
-MemFlywheel
-Copyright 2026 iFLYTEK CO., LTD.
-
-This product includes software developed at:
-- TypeBox (https://github.com/sinclairzx81/typebox), licensed under MIT License
-- TypeScript (https://www.typescriptlang.org/), licensed under Apache License 2.0
-- DefinitelyTyped Node.js type definitions (https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node), licensed under MIT License
-- undici-types (https://undici.nodejs.org), licensed under MIT License
-
-Third-party licenses are listed in the THIRD_PARTY_LICENSES file.
-For the main project license, see LICENSE.
diff --git a/packages/hermes-plugin/README.md b/packages/hermes-plugin/README.md
deleted file mode 100644
index efddf14..0000000
--- a/packages/hermes-plugin/README.md
+++ /dev/null
@@ -1,120 +0,0 @@
-# MemFlywheel Hermes Plugin
-
-Installs MemFlywheel as a Hermes `MemoryProvider`.
-
-## Install From npm
-
-```bash
-npm install -g @iflytekopensource/hermes
-memflywheel-hermes-install
-hermes config set memory.provider memflywheel
-hermes memory status
-```
-
-Then start Hermes normally:
-
-```bash
-hermes --tui
-```
-
-MemFlywheel uses Hermes' own model, authentication, lifecycle, and tool
-execution policy. It does not expose a memory recall tool to the main model.
-
-Default behavior: file-native recall, turn-end extraction, session-end dream
-cleanup, secret refusal, and learned-skill evolution are enabled. Set
-`MEMFLYWHEEL_LEARNED_SKILLS=false` or configure `learned_skills=false` only when
-you need to disable skill learning explicitly.
-
-## What The Installer Does
-
-`memflywheel-hermes-install` installs the provider into:
-
-```text
-$HERMES_HOME/plugins/memflywheel
-$HERMES_HOME/plugins/memflywheel-guard
-```
-
-If `HERMES_HOME` is unset, Hermes' default home is used:
-
-```text
-~/.hermes/plugins/memflywheel
-```
-
-The installer also:
-
-| Step | Effect |
-| -------------------------- | ----------------------------------------------------------------------------------------------- |
-| Copy provider files | Installs `__init__.py`, `worker.mjs`, and `plugin.yaml` |
-| Enforce single-writer | Enables `memflywheel-guard`; host reads stay native, host writes to the memory root are blocked |
-| Pin adapter import | Writes `install.json` so the worker loads the npm-installed adapter |
-| Disable native memory tool | Adds `memory` to `agent.disabled_toolsets` |
-| Preserve old native memory | Moves `memories/MEMORY.md` to `memories.disabled-by-memflywheel/` |
-
-## Runtime Files
-
-MemFlywheel stores memory under `$MEMFLYWHEEL_HOME` when set. Otherwise it uses:
-
-```text
-$HERMES_HOME/memflywheel
-```
-
-Common paths:
-
-```text
-~/.hermes/memflywheel/MEMORY.md
-~/.hermes/memflywheel/preference/*.md
-~/.hermes/memflywheel/workflow/*.md
-~/.hermes/memflywheel/.memflywheel/sources/*.jsonl
-~/.hermes/memflywheel/learned-skills/*/SKILL.md
-~/.hermes/skills/memflywheel/*/SKILL.md
-```
-
-The last path is the Hermes-native skill mirror. MemFlywheel learns skills in its
-own store, then mirrors them into Hermes' skill ecosystem.
-
-## Flow
-
-```text
-Hermes prompt build
- |
- v
-MemFlywheel prefetch -> MEMORY.md cues + learned-skill routes
- |
- v
-Hermes main model runs normally
- |
- v
-Hermes turn end
- |
- v
-MemFlywheel extraction -> source trace -> skill evolution -> dream
- |
- v
-Hermes skill mirror sync
-```
-
-Hermes owns the user-facing Agent. MemFlywheel only owns the file-native memory
-and learning loop.
-
-## Verify
-
-```bash
-hermes plugins list | grep memflywheel
-hermes memory status
-find ~/.hermes/memflywheel -maxdepth 3 -print
-find ~/.hermes/skills/memflywheel -maxdepth 3 -print
-```
-
-After a real session, expect memory files under `~/.hermes/memflywheel` and
-learned-skill mirrors under `~/.hermes/skills/memflywheel`.
-
-## Source Checkout
-
-```bash
-pnpm --filter @iflytekopensource/hermes run build
-pnpm --filter @iflytekopensource/hermes run install:local
-hermes config set memory.provider memflywheel
-```
-
-This is equivalent to the npm path above, except the installer is executed from
-the source checkout instead of the globally installed package.
diff --git a/packages/hermes-plugin/THIRD_PARTY_LICENSES b/packages/hermes-plugin/THIRD_PARTY_LICENSES
deleted file mode 100644
index aa7c6fd..0000000
--- a/packages/hermes-plugin/THIRD_PARTY_LICENSES
+++ /dev/null
@@ -1,33 +0,0 @@
-Third-Party Licenses
-====================
-
-This file lists third-party npm packages used by the MemFlywheel source
-workspace for build, test, and type-checking. The MemFlywheel packages
-themselves are licensed under the repository LICENSE file.
-
-Published MemFlywheel packages do not bundle third-party runtime libraries.
-The packages below are used for source validation, type-checking, and builds.
-
-Package: typebox
-Version: 1.3.1
-Homepage: https://github.com/sinclairzx81/typebox
-License: MIT
-Copyright: Copyright (c) 2017-2026 Haydn Paterson
-
-Package: typescript
-Version: 5.9.3
-Homepage: https://www.typescriptlang.org/
-License: Apache-2.0
-Copyright: Microsoft Corp.
-
-Package: @types/node
-Version: 22.20.0
-Homepage: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node
-License: MIT
-Copyright: Copyright (c) Microsoft Corporation.
-
-Package: undici-types
-Version: 6.21.0
-Homepage: https://undici.nodejs.org
-License: MIT
-Copyright: Copyright (c) Matteo Collina and Undici contributors
diff --git a/packages/hermes-plugin/package.json b/packages/hermes-plugin/package.json
deleted file mode 100644
index a5bf593..0000000
--- a/packages/hermes-plugin/package.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "name": "@iflytekopensource/hermes",
- "version": "0.1.1",
- "description": "Hermes MemoryProvider plugin for MemFlywheel.",
- "license": "Apache-2.0",
- "type": "module",
- "bin": {
- "memflywheel-hermes-install": "bin/install.mjs"
- },
- "files": [
- "LICENSE",
- "NOTICE",
- "THIRD_PARTY_LICENSES",
- "bin/**/*.mjs",
- "bridge/**/*.mjs",
- "guard/**/*.py",
- "guard/plugin.yaml",
- "provider/**/*.py",
- "plugin.yaml",
- "README.md"
- ],
- "scripts": {
- "build": "node --check bridge/worker.mjs && node --check bin/install.mjs && python3 - <<'PY'\nfrom pathlib import Path\nfor file in ('provider/__init__.py', 'guard/__init__.py'):\n compile(Path(file).read_text(), file, 'exec')\nPY",
- "install:local": "node bin/install.mjs",
- "test": "PYTHONDONTWRITEBYTECODE=1 python3 tests/provider_contract_test.py"
- },
- "dependencies": {
- "@earendil-works/pi-ai": "^0.82.1",
- "@iflytekopensource/adapters": "workspace:*"
- },
- "engines": {
- "node": ">=22.13"
- },
- "publishConfig": {
- "access": "public"
- }
-}
diff --git a/packages/adapters/LICENSE b/packages/memflywheel/LICENSE
similarity index 100%
rename from packages/adapters/LICENSE
rename to packages/memflywheel/LICENSE
diff --git a/packages/adapters/NOTICE b/packages/memflywheel/NOTICE
similarity index 100%
rename from packages/adapters/NOTICE
rename to packages/memflywheel/NOTICE
diff --git a/packages/adapters/README.md b/packages/memflywheel/README.md
similarity index 87%
rename from packages/adapters/README.md
rename to packages/memflywheel/README.md
index 39d58ee..648b22d 100644
--- a/packages/adapters/README.md
+++ b/packages/memflywheel/README.md
@@ -1,9 +1,9 @@
-# @iflytekopensource/adapters
+# @iflytekopensource/memflywheel
-Host lifecycle mappings and native model bindings for MemFlywheel. Each adapter
-translates host lifecycle events onto MemFlywheel hooks and resolves the host's
-active model into a `pi-ai` stream. Core memory semantics remain in the bundled
-MemFlywheel Core and SDK layers.
+The single public MemFlywheel package for Pi, Hermes, OpenCode, and OpenClaw. It
+contains the host lifecycle mappings, native model bindings, direct package
+entrypoints, and the Hermes MemoryProvider installer. Core memory semantics
+remain in the bundled MemFlywheel Core and SDK layers.
The package installs Pi Agent Core, `pi-ai`, and `proper-lockfile` as runtime
dependencies.
@@ -17,21 +17,27 @@ dependencies.
| `openclaw` | OpenClaw | `before_prompt_build` | `agent_end` | `session_end` | real |
| `opencode` | OpenCode | `experimental.chat.system.transform` | `experimental.text.complete` / `session.idle` | `session.deleted` | real |
-`@iflytekopensource/adapters` owns the shared host adapter/runtime layer. Host-specific
-install shape still differs: Pi, OpenCode, and OpenClaw can load package
-entrypoints directly, while Hermes needs the `@iflytekopensource/hermes` package to
-install its Python `MemoryProvider`, config wiring, and skill mirror.
+Host-specific installation still differs: Pi, OpenCode, and OpenClaw load
+package entrypoints directly, while Hermes runs the included
+`memflywheel-hermes-install` command to install its Python `MemoryProvider`,
+config wiring, and skill mirror.
-- **`pi`** — real: `@iflytekopensource/adapters` is a Pi package. Its
+- **`pi`** — real: `@iflytekopensource/memflywheel` is a Pi package. Its
`package.json` declares `pi.extensions`, and Pi installs it with
- `pi install npm:@iflytekopensource/adapters`.
+ `pi install npm:@iflytekopensource/memflywheel`.
`context` → `onPromptBuild`; `agent_end` → `onTurnEnd`; and
`session_shutdown` → `onSessionEnd`.
-- **`hermes`** — real: `@iflytekopensource/hermes` installs a Hermes
- `MemoryProvider`, and its bridge imports `@iflytekopensource/adapters` for the
- shared runtime. `prefetch` builds recall context, `sync_turn` runs the
+- **`hermes`** — real: `memflywheel-hermes-install` installs a Hermes
+ `MemoryProvider`, whose bridge imports this package's shared runtime.
+ `prefetch` builds recall context, `sync_turn` runs the
write-side lifecycle, and session end coordinates idle consolidation.
+```sh
+npm install -g @iflytekopensource/memflywheel
+memflywheel-hermes-install
+hermes config set memory.provider memflywheel
+```
+
Each adapter declares a `defaultConfigRelPath` (the host config under `$HOME`) and
an `integrationNote` describing how the host actually consumes the scribe.
@@ -58,7 +64,7 @@ object with the lifecycle hooks satisfies it, including the runtime assembled by
`createMemFlywheelHarnessRuntime(...)`.
```ts
-import { piAdapter } from "@iflytekopensource/adapters";
+import { piAdapter } from "@iflytekopensource/memflywheel";
const dispose = piAdapter.attach(scribe, host);
// ... later
@@ -113,7 +119,7 @@ for (const f of await piAdapter.doctor({ configPath })) {
Build one from a lifecycle map + payload translators with `makeAdapter`:
```ts
-import { makeAdapter, normalizeMessages, readString } from "@iflytekopensource/adapters";
+import { makeAdapter, normalizeMessages, readString } from "@iflytekopensource/memflywheel";
export const myAdapter = makeAdapter({
id: "my-host",
@@ -144,7 +150,7 @@ pi-ai `Model` + `StreamFn` and exposes lifecycle events through `HostHarnessPort
Evolution on the single Pi Agent Core runner.
```ts
-import { createMemFlywheelHarnessRuntime } from "@iflytekopensource/adapters";
+import { createMemFlywheelHarnessRuntime } from "@iflytekopensource/memflywheel";
const { scribe, sdk } = createMemFlywheelHarnessRuntime({ port });
```
@@ -152,7 +158,10 @@ const { scribe, sdk } = createMemFlywheelHarnessRuntime({ port });
Pi phase-1 native integration uses a host port:
```ts
-import { createMemFlywheelHarnessRuntime, createPiHarnessPort } from "@iflytekopensource/adapters";
+import {
+ createMemFlywheelHarnessRuntime,
+ createPiHarnessPort,
+} from "@iflytekopensource/memflywheel";
import { streamSimple } from "@earendil-works/pi-ai/compat";
export default function memFlywheelExtension(pi) {
@@ -237,7 +246,7 @@ const { scribe } = createMemFlywheelHarnessRuntime({ mode: "recall-only" });
applies it and immediately re-reads from disk to verify the marker round-trips:
```ts
-import { connect, piAdapter } from "@iflytekopensource/adapters";
+import { connect, piAdapter } from "@iflytekopensource/memflywheel";
const plan = await connect(piAdapter); // plan only, no writes
const res = await connect(piAdapter, { apply: true }); // write + verify
diff --git a/packages/adapters/THIRD_PARTY_LICENSES b/packages/memflywheel/THIRD_PARTY_LICENSES
similarity index 100%
rename from packages/adapters/THIRD_PARTY_LICENSES
rename to packages/memflywheel/THIRD_PARTY_LICENSES
diff --git a/packages/hermes-plugin/bin/install.mjs b/packages/memflywheel/bin/install.mjs
old mode 100644
new mode 100755
similarity index 95%
rename from packages/hermes-plugin/bin/install.mjs
rename to packages/memflywheel/bin/install.mjs
index 02e7c41..e1be88d
--- a/packages/hermes-plugin/bin/install.mjs
+++ b/packages/memflywheel/bin/install.mjs
@@ -32,7 +32,7 @@ If HERMES_HOME is unset, the installer uses ~/.hermes.`);
const hermesHome = process.env.HERMES_HOME || join(homedir(), ".hermes");
const target = join(hermesHome, "plugins", "memflywheel");
const guardTarget = join(hermesHome, "plugins", "memflywheel-guard");
-const adaptersImport = await import.meta.resolve("@iflytekopensource/adapters");
+const packageImport = new URL("../dist/index.js", import.meta.url).href;
function disableHermesNativeMemoryTool(configPath) {
if (!existsSync(configPath)) return false;
@@ -80,7 +80,7 @@ mkdirSync(target, { recursive: true });
copyFileSync(join(root, "provider", "__init__.py"), join(target, "__init__.py"));
copyFileSync(join(root, "bridge", "worker.mjs"), join(target, "worker.mjs"));
copyFileSync(join(root, "plugin.yaml"), join(target, "plugin.yaml"));
-writeFileSync(join(target, "install.json"), `${JSON.stringify({ adaptersImport }, null, 2)}\n`, {
+writeFileSync(join(target, "install.json"), `${JSON.stringify({ packageImport }, null, 2)}\n`, {
mode: 0o600,
});
@@ -90,7 +90,7 @@ copyFileSync(join(root, "guard", "plugin.yaml"), join(guardTarget, "plugin.yaml"
const enabled = spawnSync(
"hermes",
- ["plugins", "enable", "memflywheel-guard", "--no-allow-tool-override"],
+ ["plugins", "enable", "memflywheel-guard", "--allow-tool-override"],
{ env: { ...process.env, HERMES_HOME: hermesHome }, encoding: "utf8" },
);
if (enabled.status !== 0) {
diff --git a/packages/hermes-plugin/bridge/worker.mjs b/packages/memflywheel/bridge/worker.mjs
similarity index 98%
rename from packages/hermes-plugin/bridge/worker.mjs
rename to packages/memflywheel/bridge/worker.mjs
index f3cf2df..d686c8a 100644
--- a/packages/hermes-plugin/bridge/worker.mjs
+++ b/packages/memflywheel/bridge/worker.mjs
@@ -12,11 +12,11 @@ import {
import { homedir } from "node:os";
import { dirname, join } from "node:path";
-const adapters = await import(
- process.env.MEMFLYWHEEL_ADAPTERS_IMPORT || "@iflytekopensource/adapters"
+const memflywheel = await import(
+ process.env.MEMFLYWHEEL_PACKAGE_IMPORT || "@iflytekopensource/memflywheel"
);
const { createAssistantMessageEventStream, createMemFlywheelHarnessRuntime, normalizeMessages } =
- adapters;
+ memflywheel;
let runtime;
let runtimeKey = "";
diff --git a/packages/hermes-plugin/guard/__init__.py b/packages/memflywheel/guard/__init__.py
similarity index 100%
rename from packages/hermes-plugin/guard/__init__.py
rename to packages/memflywheel/guard/__init__.py
diff --git a/packages/hermes-plugin/guard/plugin.yaml b/packages/memflywheel/guard/plugin.yaml
similarity index 90%
rename from packages/hermes-plugin/guard/plugin.yaml
rename to packages/memflywheel/guard/plugin.yaml
index 0ee2186..ed3dc68 100644
--- a/packages/hermes-plugin/guard/plugin.yaml
+++ b/packages/memflywheel/guard/plugin.yaml
@@ -1,5 +1,5 @@
name: memflywheel-guard
-version: 0.1.0
+version: 0.1.1
kind: standalone
description: "Read-only host boundary for the MemFlywheel memory root."
provides_hooks:
diff --git a/packages/adapters/openclaw-extension/index.mjs b/packages/memflywheel/openclaw-extension/index.mjs
similarity index 100%
rename from packages/adapters/openclaw-extension/index.mjs
rename to packages/memflywheel/openclaw-extension/index.mjs
diff --git a/packages/adapters/openclaw.plugin.json b/packages/memflywheel/openclaw.plugin.json
similarity index 100%
rename from packages/adapters/openclaw.plugin.json
rename to packages/memflywheel/openclaw.plugin.json
diff --git a/packages/adapters/opencode-plugin/index.js b/packages/memflywheel/opencode-plugin/index.js
similarity index 100%
rename from packages/adapters/opencode-plugin/index.js
rename to packages/memflywheel/opencode-plugin/index.js
diff --git a/packages/adapters/opencode-plugin/index.mjs b/packages/memflywheel/opencode-plugin/index.mjs
similarity index 100%
rename from packages/adapters/opencode-plugin/index.mjs
rename to packages/memflywheel/opencode-plugin/index.mjs
diff --git a/packages/adapters/package.json b/packages/memflywheel/package.json
similarity index 70%
rename from packages/adapters/package.json
rename to packages/memflywheel/package.json
index 70dfb55..9923986 100644
--- a/packages/adapters/package.json
+++ b/packages/memflywheel/package.json
@@ -1,11 +1,14 @@
{
- "name": "@iflytekopensource/adapters",
+ "name": "@iflytekopensource/memflywheel",
"version": "0.1.1",
- "description": "Host lifecycle adapters for wiring MemFlywheel into agent runtimes.",
+ "description": "File-native long-term memory for Pi, Hermes, OpenCode, and OpenClaw.",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+ "bin": {
+ "memflywheel-hermes-install": "bin/install.mjs"
+ },
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -18,6 +21,12 @@
"files": [
"NOTICE",
"THIRD_PARTY_LICENSES",
+ "bin/**/*.mjs",
+ "bridge/**/*.mjs",
+ "guard/**/*.py",
+ "guard/plugin.yaml",
+ "provider/**/*.py",
+ "plugin.yaml",
"pi-extension/**/*.mjs",
"!pi-extension/**/*.test.mjs",
"opencode-plugin/**/*.js",
@@ -30,7 +39,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/iflytek/memflywheel.git",
- "directory": "packages/adapters"
+ "directory": "packages/memflywheel"
},
"bugs": {
"url": "https://github.com/iflytek/memflywheel/issues"
@@ -39,8 +48,12 @@
"keywords": [
"agent-memory",
"adapters",
+ "hermes",
"llm",
"memory",
+ "openclaw",
+ "opencode",
+ "pi",
"pi-package",
"typescript"
],
@@ -64,8 +77,10 @@
"node": ">=22.19"
},
"scripts": {
- "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -b && tsup src/index.ts --format esm --target node22 --dts --dts-resolve --out-dir dist --no-splitting --clean false --tsconfig tsconfig.bundle.json --external @earendil-works/pi-ai --external @earendil-works/pi-agent-core --external proper-lockfile --external openclaw --external 'openclaw/*'",
- "test": "pnpm run build && node --test \"dist/**/*.test.js\" \"pi-extension/**/*.test.mjs\"",
+ "build": "rm -rf dist tsconfig.tsbuildinfo && tsc -b && tsup src/index.ts --format esm --target node22 --dts --dts-resolve --out-dir dist --no-splitting --clean false --tsconfig tsconfig.bundle.json --external @earendil-works/pi-ai --external @earendil-works/pi-agent-core --external proper-lockfile --external openclaw --external 'openclaw/*' && node --check bridge/worker.mjs && node --check bin/install.mjs && python3 - <<'PY'\nfrom pathlib import Path\nfor file in ('provider/__init__.py', 'guard/__init__.py'):\n compile(Path(file).read_text(), file, 'exec')\nPY",
+ "install:hermes": "node bin/install.mjs",
+ "install:local": "node bin/install.mjs",
+ "test": "pnpm run build && node --test \"dist/**/*.test.js\" \"pi-extension/**/*.test.mjs\" && PYTHONDONTWRITEBYTECODE=1 python3 tests/provider_contract_test.py",
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
"dependencies": {
diff --git a/packages/adapters/pi-extension/index.mjs b/packages/memflywheel/pi-extension/index.mjs
similarity index 100%
rename from packages/adapters/pi-extension/index.mjs
rename to packages/memflywheel/pi-extension/index.mjs
diff --git a/packages/adapters/pi-extension/sync.mjs b/packages/memflywheel/pi-extension/sync.mjs
similarity index 100%
rename from packages/adapters/pi-extension/sync.mjs
rename to packages/memflywheel/pi-extension/sync.mjs
diff --git a/packages/adapters/pi-extension/sync.test.mjs b/packages/memflywheel/pi-extension/sync.test.mjs
similarity index 100%
rename from packages/adapters/pi-extension/sync.test.mjs
rename to packages/memflywheel/pi-extension/sync.test.mjs
diff --git a/packages/hermes-plugin/plugin.yaml b/packages/memflywheel/plugin.yaml
similarity index 87%
rename from packages/hermes-plugin/plugin.yaml
rename to packages/memflywheel/plugin.yaml
index fff5cff..b9efb58 100644
--- a/packages/hermes-plugin/plugin.yaml
+++ b/packages/memflywheel/plugin.yaml
@@ -1,3 +1,3 @@
name: memflywheel
-version: 0.1.0
+version: 0.1.1
description: "MemFlywheel — file-native long-term memory and learning loop for Hermes."
diff --git a/packages/hermes-plugin/provider/__init__.py b/packages/memflywheel/provider/__init__.py
similarity index 97%
rename from packages/hermes-plugin/provider/__init__.py
rename to packages/memflywheel/provider/__init__.py
index e6afdd6..11b2728 100644
--- a/packages/hermes-plugin/provider/__init__.py
+++ b/packages/memflywheel/provider/__init__.py
@@ -80,10 +80,10 @@ def _worker_import_ok() -> bool:
if not shutil.which("node") or not _worker_path().exists():
return False
env = os.environ.copy()
- adapters_import = _install_config().get("adaptersImport")
- if adapters_import:
- env["MEMFLYWHEEL_ADAPTERS_IMPORT"] = adapters_import
- script = "await import(process.env.MEMFLYWHEEL_ADAPTERS_IMPORT || '@iflytekopensource/adapters')"
+ package_import = _install_config().get("packageImport")
+ if package_import:
+ env["MEMFLYWHEEL_PACKAGE_IMPORT"] = package_import
+ script = "await import(process.env.MEMFLYWHEEL_PACKAGE_IMPORT || '@iflytekopensource/memflywheel')"
try:
return (
subprocess.run(
@@ -268,9 +268,9 @@ def _ensure_process(self) -> subprocess.Popen:
if self._proc and self._proc.poll() is None:
return self._proc
env = os.environ.copy()
- adapters_import = _install_config().get("adaptersImport")
- if adapters_import:
- env["MEMFLYWHEEL_ADAPTERS_IMPORT"] = adapters_import
+ package_import = _install_config().get("packageImport")
+ if package_import:
+ env["MEMFLYWHEEL_PACKAGE_IMPORT"] = package_import
self._proc = subprocess.Popen(
["node", str(_worker_path())],
stdin=subprocess.PIPE,
diff --git a/packages/adapters/src/adapter.ts b/packages/memflywheel/src/adapter.ts
similarity index 100%
rename from packages/adapters/src/adapter.ts
rename to packages/memflywheel/src/adapter.ts
diff --git a/packages/adapters/src/connect.test.ts b/packages/memflywheel/src/connect.test.ts
similarity index 100%
rename from packages/adapters/src/connect.test.ts
rename to packages/memflywheel/src/connect.test.ts
diff --git a/packages/adapters/src/harness-port.test.ts b/packages/memflywheel/src/harness-port.test.ts
similarity index 100%
rename from packages/adapters/src/harness-port.test.ts
rename to packages/memflywheel/src/harness-port.test.ts
diff --git a/packages/adapters/src/harness-port.ts b/packages/memflywheel/src/harness-port.ts
similarity index 100%
rename from packages/adapters/src/harness-port.ts
rename to packages/memflywheel/src/harness-port.ts
diff --git a/packages/adapters/src/hermes.ts b/packages/memflywheel/src/hermes.ts
similarity index 100%
rename from packages/adapters/src/hermes.ts
rename to packages/memflywheel/src/hermes.ts
diff --git a/packages/adapters/src/host-memflywheel.test.ts b/packages/memflywheel/src/host-memflywheel.test.ts
similarity index 100%
rename from packages/adapters/src/host-memflywheel.test.ts
rename to packages/memflywheel/src/host-memflywheel.test.ts
diff --git a/packages/adapters/src/host-memflywheel.ts b/packages/memflywheel/src/host-memflywheel.ts
similarity index 99%
rename from packages/adapters/src/host-memflywheel.ts
rename to packages/memflywheel/src/host-memflywheel.ts
index 104666b..4f79c03 100644
--- a/packages/adapters/src/host-memflywheel.ts
+++ b/packages/memflywheel/src/host-memflywheel.ts
@@ -51,7 +51,7 @@ import {
} from "./harness-port.js";
/**
- * Re-exported SDK contracts so hosts/adapters depend only on `@iflytekopensource/adapters`.
+ * Re-exported SDK contracts so hosts/adapters depend only on `@iflytekopensource/memflywheel`.
*/
export type {
MemFlywheelLearningLoopConfig,
diff --git a/packages/adapters/src/index.ts b/packages/memflywheel/src/index.ts
similarity index 98%
rename from packages/adapters/src/index.ts
rename to packages/memflywheel/src/index.ts
index 872574b..5226e66 100644
--- a/packages/adapters/src/index.ts
+++ b/packages/memflywheel/src/index.ts
@@ -1,5 +1,5 @@
/**
- * @iflytekopensource/adapters — host lifecycle mappings.
+ * @iflytekopensource/memflywheel — host lifecycle mappings.
*
* Each adapter maps a host's lifecycle events (session start, prompt build,
* turn end, idle/scheduled) onto a MemFlywheel's hooks. Adapters contain NO
diff --git a/packages/adapters/src/install.test.ts b/packages/memflywheel/src/install.test.ts
similarity index 100%
rename from packages/adapters/src/install.test.ts
rename to packages/memflywheel/src/install.test.ts
diff --git a/packages/adapters/src/lifecycle.test.ts b/packages/memflywheel/src/lifecycle.test.ts
similarity index 100%
rename from packages/adapters/src/lifecycle.test.ts
rename to packages/memflywheel/src/lifecycle.test.ts
diff --git a/packages/adapters/src/make-adapter.ts b/packages/memflywheel/src/make-adapter.ts
similarity index 100%
rename from packages/adapters/src/make-adapter.ts
rename to packages/memflywheel/src/make-adapter.ts
diff --git a/packages/adapters/src/model-test-support.test.ts b/packages/memflywheel/src/model-test-support.test.ts
similarity index 100%
rename from packages/adapters/src/model-test-support.test.ts
rename to packages/memflywheel/src/model-test-support.test.ts
diff --git a/packages/adapters/src/openclaw-native-model.ts b/packages/memflywheel/src/openclaw-native-model.ts
similarity index 100%
rename from packages/adapters/src/openclaw-native-model.ts
rename to packages/memflywheel/src/openclaw-native-model.ts
diff --git a/packages/adapters/src/openclaw-port.test.ts b/packages/memflywheel/src/openclaw-port.test.ts
similarity index 100%
rename from packages/adapters/src/openclaw-port.test.ts
rename to packages/memflywheel/src/openclaw-port.test.ts
diff --git a/packages/adapters/src/openclaw-port.ts b/packages/memflywheel/src/openclaw-port.ts
similarity index 100%
rename from packages/adapters/src/openclaw-port.ts
rename to packages/memflywheel/src/openclaw-port.ts
diff --git a/packages/adapters/src/openclaw.ts b/packages/memflywheel/src/openclaw.ts
similarity index 100%
rename from packages/adapters/src/openclaw.ts
rename to packages/memflywheel/src/openclaw.ts
diff --git a/packages/adapters/src/opencode-pi-model.ts b/packages/memflywheel/src/opencode-pi-model.ts
similarity index 100%
rename from packages/adapters/src/opencode-pi-model.ts
rename to packages/memflywheel/src/opencode-pi-model.ts
diff --git a/packages/adapters/src/opencode-port.test.ts b/packages/memflywheel/src/opencode-port.test.ts
similarity index 97%
rename from packages/adapters/src/opencode-port.test.ts
rename to packages/memflywheel/src/opencode-port.test.ts
index 9bbfe4c..26cee74 100644
--- a/packages/adapters/src/opencode-port.test.ts
+++ b/packages/memflywheel/src/opencode-port.test.ts
@@ -99,7 +99,7 @@ test("OpenCode port injects recall and forwards the real idle transcript", async
]);
});
-test("OpenCode serializes text-complete and idle through one deduplicated turn submitter", async () => {
+test("OpenCode buffers text-complete without blocking output, then serializes idle delivery", async () => {
let transcript = {
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] }],
};
@@ -114,7 +114,7 @@ test("OpenCode serializes text-complete and idle through one deduplicated turn s
{ sessionID: "oc-idle", messageID: "m1", partID: "p1" },
{ text: "noted" },
);
- assert.equal(turns.length, 1);
+ assert.equal(turns.length, 0);
const idle = { event: { type: "session.idle", properties: { sessionID: "oc-idle" } } };
await Promise.all([port.hooks.event(idle), port.hooks.event(idle)]);
diff --git a/packages/adapters/src/opencode-port.ts b/packages/memflywheel/src/opencode-port.ts
similarity index 99%
rename from packages/adapters/src/opencode-port.ts
rename to packages/memflywheel/src/opencode-port.ts
index 562761b..ecfc248 100644
--- a/packages/adapters/src/opencode-port.ts
+++ b/packages/memflywheel/src/opencode-port.ts
@@ -527,7 +527,6 @@ export function createOpenCodeHarnessPort(
: { messageId: input.messageID, parts: new Map() };
pending.parts.set(input.partID, output.text);
pendingCompletedText.set(input.sessionID, pending);
- await dispatchCompletedTurn(input.sessionID);
},
async "tool.execute.before"(input, output) {
for (const handler of toolCallHandlers) {
diff --git a/packages/adapters/src/opencode.ts b/packages/memflywheel/src/opencode.ts
similarity index 100%
rename from packages/adapters/src/opencode.ts
rename to packages/memflywheel/src/opencode.ts
diff --git a/packages/adapters/src/pi-port.test.ts b/packages/memflywheel/src/pi-port.test.ts
similarity index 100%
rename from packages/adapters/src/pi-port.test.ts
rename to packages/memflywheel/src/pi-port.test.ts
diff --git a/packages/adapters/src/pi-port.ts b/packages/memflywheel/src/pi-port.ts
similarity index 100%
rename from packages/adapters/src/pi-port.ts
rename to packages/memflywheel/src/pi-port.ts
diff --git a/packages/adapters/src/pi.ts b/packages/memflywheel/src/pi.ts
similarity index 94%
rename from packages/adapters/src/pi.ts
rename to packages/memflywheel/src/pi.ts
index f5356f8..b86d7a6 100644
--- a/packages/adapters/src/pi.ts
+++ b/packages/memflywheel/src/pi.ts
@@ -49,7 +49,7 @@ const basePiAdapter = makeAdapter({
lifecycle,
defaultConfigRelPath: ".pi/agent/settings.json",
integrationNote:
- "Native integration: a Pi extension builds `createPiHarnessPort(pi, { completeSimple })` and passes it to `createMemFlywheelHarnessRuntime`; settings.json carries the wiring marker.",
+ "Native integration: a Pi extension builds `createPiHarnessPort(pi, { streamSimple })` and passes it to `createMemFlywheelHarnessRuntime`; settings.json carries the wiring marker.",
translators: {
sessionId: (payload) => readString(payload, "sessionId"),
turnEnd: (payload) => ({
diff --git a/packages/adapters/src/registry.test.ts b/packages/memflywheel/src/registry.test.ts
similarity index 100%
rename from packages/adapters/src/registry.test.ts
rename to packages/memflywheel/src/registry.test.ts
diff --git a/packages/adapters/src/registry.ts b/packages/memflywheel/src/registry.ts
similarity index 100%
rename from packages/adapters/src/registry.ts
rename to packages/memflywheel/src/registry.ts
diff --git a/packages/adapters/src/test-helpers.ts b/packages/memflywheel/src/test-helpers.ts
similarity index 100%
rename from packages/adapters/src/test-helpers.ts
rename to packages/memflywheel/src/test-helpers.ts
diff --git a/packages/adapters/src/verify-doctor.test.ts b/packages/memflywheel/src/verify-doctor.test.ts
similarity index 100%
rename from packages/adapters/src/verify-doctor.test.ts
rename to packages/memflywheel/src/verify-doctor.test.ts
diff --git a/packages/hermes-plugin/tests/provider_contract_test.py b/packages/memflywheel/tests/provider_contract_test.py
similarity index 98%
rename from packages/hermes-plugin/tests/provider_contract_test.py
rename to packages/memflywheel/tests/provider_contract_test.py
index a2a4d07..89341dc 100644
--- a/packages/hermes-plugin/tests/provider_contract_test.py
+++ b/packages/memflywheel/tests/provider_contract_test.py
@@ -121,11 +121,11 @@ def register_memory_provider(self, provider):
assert (guard / "__init__.py").exists()
assert (guard / "plugin.yaml").exists()
install_metadata = json.loads((installed / "install.json").read_text(encoding="utf-8"))
- assert "adaptersImport" in install_metadata
+ assert "packageImport" in install_metadata
installed_worker = subprocess.run(
["node", str(installed / "worker.mjs")],
cwd=installed,
- env={**env, "MEMFLYWHEEL_ADAPTERS_IMPORT": install_metadata["adaptersImport"]},
+ env={**env, "MEMFLYWHEEL_PACKAGE_IMPORT": install_metadata["packageImport"]},
input=json.dumps(
{
"type": "command",
diff --git a/packages/adapters/tsconfig.bundle.json b/packages/memflywheel/tsconfig.bundle.json
similarity index 100%
rename from packages/adapters/tsconfig.bundle.json
rename to packages/memflywheel/tsconfig.bundle.json
diff --git a/packages/adapters/tsconfig.json b/packages/memflywheel/tsconfig.json
similarity index 100%
rename from packages/adapters/tsconfig.json
rename to packages/memflywheel/tsconfig.json
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd3b8e8..e468526 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,9 +50,9 @@ importers:
examples:
dependencies:
- '@iflytekopensource/adapters':
+ '@iflytekopensource/memflywheel':
specifier: workspace:*
- version: link:../packages/adapters
+ version: link:../packages/memflywheel
'@memflywheel/core':
specifier: workspace:*
version: link:../packages/core
@@ -63,28 +63,6 @@ importers:
specifier: workspace:*
version: link:../packages/skills
- packages/adapters:
- dependencies:
- '@earendil-works/pi-agent-core':
- specifier: ^0.82.1
- version: 0.82.1(ws@8.21.1)(zod@4.4.3)
- '@earendil-works/pi-ai':
- specifier: ^0.82.1
- version: 0.82.1(ws@8.21.1)(zod@4.4.3)
- proper-lockfile:
- specifier: ^4.1.2
- version: 4.1.2
- devDependencies:
- '@types/node':
- specifier: ^22.20.1
- version: 22.20.1
- tsup:
- specifier: ^8.5.1
- version: 8.5.1(typescript@5.9.3)(yaml@2.9.0)
- typescript:
- specifier: ^5.5.4
- version: 5.9.3
-
packages/core:
dependencies:
proper-lockfile:
@@ -110,14 +88,27 @@ importers:
specifier: ^5.5.4
version: 5.9.3
- packages/hermes-plugin:
+ packages/memflywheel:
dependencies:
+ '@earendil-works/pi-agent-core':
+ specifier: ^0.82.1
+ version: 0.82.1(ws@8.21.1)(zod@4.4.3)
'@earendil-works/pi-ai':
specifier: ^0.82.1
version: 0.82.1(ws@8.21.1)(zod@4.4.3)
- '@iflytekopensource/adapters':
- specifier: workspace:*
- version: link:../adapters
+ proper-lockfile:
+ specifier: ^4.1.2
+ version: 4.1.2
+ devDependencies:
+ '@types/node':
+ specifier: ^22.20.1
+ version: 22.20.1
+ tsup:
+ specifier: ^8.5.1
+ version: 8.5.1(typescript@5.9.3)(yaml@2.9.0)
+ typescript:
+ specifier: ^5.5.4
+ version: 5.9.3
packages/sdk:
dependencies:
diff --git a/scripts/publish-npm.mjs b/scripts/publish-npm.mjs
index eedc27b..c6327d2 100644
--- a/scripts/publish-npm.mjs
+++ b/scripts/publish-npm.mjs
@@ -7,7 +7,7 @@ import { join } from "node:path";
const GHP_REGISTRY = "https://npm.pkg.github.com";
const SCOPE_FROM = "@iflytekopensource/";
const SCOPE_TO = "@iflytek/";
-const TARGET_PACKAGES = ["@iflytekopensource/adapters", "@iflytekopensource/hermes"];
+const TARGET_PACKAGES = ["@iflytekopensource/memflywheel"];
function rewriteScope(value) {
return value.split(SCOPE_FROM).join(SCOPE_TO);
@@ -84,8 +84,8 @@ function publishToGitHubPackages(publishArgs) {
}
writeFileSync(pjPath, JSON.stringify(pj, null, 2) + "\n");
- // Rewrite runtime imports in hermes package
- if (pj.name === `${SCOPE_TO}hermes`) {
+ // Rewrite the unified package's runtime fallback imports.
+ if (pj.name === `${SCOPE_TO}memflywheel`) {
for (const rel of ["bin/install.mjs", "bridge/worker.mjs", "provider/__init__.py"]) {
try {
const fp = join(pkgDir, rel);
From 3fdc7f102ef71251d0222cf893f4b187a2c884e2 Mon Sep 17 00:00:00 2001
From: MemScribe Maintainers
Date: Tue, 28 Jul 2026 16:40:10 +0800
Subject: [PATCH 3/6] test: isolate the Hermes installer contract
Signed-off-by: MemScribe Maintainers
---
.../tests/provider_contract_test.py | 20 ++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/packages/memflywheel/tests/provider_contract_test.py b/packages/memflywheel/tests/provider_contract_test.py
index 89341dc..7a891ae 100644
--- a/packages/memflywheel/tests/provider_contract_test.py
+++ b/packages/memflywheel/tests/provider_contract_test.py
@@ -96,6 +96,14 @@ def register_memory_provider(self, provider):
assert collector.provider.name == "memflywheel"
with tempfile.TemporaryDirectory() as tmp:
+ fake_bin = Path(tmp) / "bin"
+ fake_bin.mkdir()
+ fake_hermes = fake_bin / "hermes"
+ fake_hermes.write_text(
+ "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HERMES_HOME/hermes-enable-args\"\n",
+ encoding="utf-8",
+ )
+ fake_hermes.chmod(0o755)
native_memory = Path(tmp) / "memories" / "MEMORY.md"
native_memory.parent.mkdir(parents=True)
native_memory.write_text("native hermes memory\n", encoding="utf-8")
@@ -104,7 +112,11 @@ def register_memory_provider(self, provider):
config = Path(tmp) / "config.yaml"
config.write_text("_config_version: 27\nagent:\n disabled_toolsets: []\n", encoding="utf-8")
- env = {**os.environ, "HERMES_HOME": tmp}
+ env = {
+ **os.environ,
+ "HERMES_HOME": tmp,
+ "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}",
+ }
subprocess.run(
["node", str(Path(__file__).resolve().parents[1] / "bin" / "install.mjs")],
cwd=Path(__file__).resolve().parents[1],
@@ -120,6 +132,12 @@ def register_memory_provider(self, provider):
guard = Path(tmp) / "plugins" / "memflywheel-guard"
assert (guard / "__init__.py").exists()
assert (guard / "plugin.yaml").exists()
+ assert (Path(tmp) / "hermes-enable-args").read_text(encoding="utf-8").splitlines() == [
+ "plugins",
+ "enable",
+ "memflywheel-guard",
+ "--allow-tool-override",
+ ]
install_metadata = json.loads((installed / "install.json").read_text(encoding="utf-8"))
assert "packageImport" in install_metadata
installed_worker = subprocess.run(
From 99d73cb2e33f7964d6cd5fa818ca3a3939e78166 Mon Sep 17 00:00:00 2001
From: MemScribe Maintainers
Date: Tue, 28 Jul 2026 16:42:58 +0800
Subject: [PATCH 4/6] ci: use the current examples smoke entrypoint
Signed-off-by: MemScribe Maintainers
---
.github/workflows/ci.yml | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a9bbf48..cfd76c5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -84,11 +84,7 @@ jobs:
- name: Examples smoke test
if: matrix.os == 'ubuntu-latest' && matrix.node-version == '22'
- run: |
- USE_FAKE=1 node examples/pi/run.mjs
- USE_FAKE=1 node examples/hermes/run.mjs
- USE_FAKE=1 node examples/openclaw/run.mjs
- USE_FAKE=1 node examples/learning-loop/run.mjs
+ run: pnpm --dir examples smoke
- name: Pack dry run
if: matrix.os == 'ubuntu-latest' && matrix.node-version == '22'
From 29f2d37bb9aecbdec1e51dcf2d328e3618ae1886 Mon Sep 17 00:00:00 2001
From: MemScribe Maintainers
Date: Tue, 28 Jul 2026 17:20:11 +0800
Subject: [PATCH 5/6] docs: restore unreleased changelog entries
Signed-off-by: MemScribe Maintainers
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6abe4a7..c27132b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,13 @@ for published packages.
### Added
+- Kubernetes-level E2E CI using kind and agent-sandbox CRDs for Pi, Hermes,
+ and OpenClaw, validating the memory lifecycle against an offline mock LLM.
- Optional embedding pre-recall for large `MEMORY.md` indexes, configured through
OpenAI-compatible embedding endpoint, API key, model, batch size, and retrieval
limit environment variables.
+- Documentation for the 200-line direct index limit and the optional embedding
+ endpoint and API-key setup used to enable pre-recall.
- Hermes host-write guard and learned-skill synchronization for native host
integration.
From 5f6fc94caa53dc878bc25b34fd20ce16e2f8402e Mon Sep 17 00:00:00 2001
From: MemScribe Maintainers
Date: Tue, 28 Jul 2026 17:29:04 +0800
Subject: [PATCH 6/6] docs: preserve the full E2E changelog details
Signed-off-by: MemScribe Maintainers
---
CHANGELOG.md | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c27132b..17df993 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,13 +11,15 @@ for published packages.
### Added
-- Kubernetes-level E2E CI using kind and agent-sandbox CRDs for Pi, Hermes,
- and OpenClaw, validating the memory lifecycle against an offline mock LLM.
+- Kubernetes-level E2E CI workflow using kind and agent-sandbox CRDs. Deploys
+ Pi, Hermes, and OpenClaw agents in separate namespaces with the MemFlywheel
+ package baked into custom Docker images, then validates the full memory
+ lifecycle against a mock LLM (offline, no API key required).
- Optional embedding pre-recall for large `MEMORY.md` indexes, configured through
OpenAI-compatible embedding endpoint, API key, model, batch size, and retrieval
limit environment variables.
-- Documentation for the 200-line direct index limit and the optional embedding
- endpoint and API-key setup used to enable pre-recall.
+- Documentation for the 200-line direct index limit and the optional
+ endpoint/API-key setup needed to enable pre-recall.
- Hermes host-write guard and learned-skill synchronization for native host
integration.