diff --git a/docs/memory-v2/README.md b/docs/memory-v2/README.md new file mode 100644 index 0000000..977b3a1 --- /dev/null +++ b/docs/memory-v2/README.md @@ -0,0 +1,910 @@ +# Memind Memory V2 + +Memory V2 is a facade and policy abstraction layer over existing Memind memory systems. + +It is not a new memory system. It does not introduce new storage, new execution paths, or a replacement runtime. + +## Scope + +Memory V2 wraps the existing: + +- `conversation-memory.mjs` +- MySQL memory tables such as `h5_conversation_messages` and `h5_user_memory_items` +- capability-driven memory hooks such as `memory_store`, `context_memory`, and `chat_recall` + +It provides: + +- unified memory API: `resolve`, `write`, `compact` +- feature-flag control plane +- backend adapter layer, with the legacy conversation memory backend first +- future optional plugin points for vector memory, extraction, lifecycle, behavior modeling, and policy routing + +It guarantees: + +- zero change to goosed runtime +- zero change to PG session behavior +- zero change to SSE protocol +- zero change to core Portal execution flow +- zero blocking on memory backend failure +- full backward compatibility with the existing `conversation-memory` system + +## Current Implementation + +The first implementation is intentionally narrow: + +- `memory-v2.mjs` defines the facade. +- `memory-v2-runtime.mjs` wires server runtime backends from explicit environment variables. +- `memory-v2-plugin-backends.mjs` defines disabled-by-default plugin slots for future backends. +- `memory-v2-pgvector.mjs` defines a disabled-by-default semantic memory adapter skeleton. +- `memory-v2-pgvector-schema.mjs` defines explicit, manual pgvector schema setup helpers. +- `createLegacyMemoryBackend(...)` adapts the existing conversation memory service. +- `resolveMemoryV2Policy(...)` maps feature flags into a stable policy object. +- `server.mjs` creates Memory V2 from the existing conversation memory service. +- `tkmind-proxy.mjs` reads user memories through `memoryV2.resolve(...)` before passing them into the existing session reconcile path. +- `/user-memory/v1/remember-recent` writes through `memoryV2.write(...)`. +- `/user-memory/v1/sync` compacts through `memoryV2.compact(...)`. +- Runtime startup does not create schema. +- No goosed or SSE behavior is changed. + +The pgvector adapter does not create tables or generate embeddings. It only defines the adapter contract for a future semantic memory backend and requires explicit `enabled: true`, an injected PostgreSQL pool, and either an input embedding or an injected `embedQuery(...)` function. + +The server runtime uses `createMemoryV2Runtime(...)`. It keeps pgvector dormant unless all of these are true: + +- `MEMORY_VECTOR_ENABLED=1` +- `MEMORY_PGVECTOR_DATABASE_URL` is set +- `MEMORY_PGVECTOR_EMBEDDING_MODULE` points to a module exporting `embedQuery(query, input)` or a default function + +If any requirement is missing, pgvector reports `available=false` and Memory V2 falls back to the legacy conversation-memory backend. The runtime never reads the app's MySQL `DATABASE_URL` for pgvector. + +When pgvector is available and `MEMORY_BACKEND=pgvector`, only `resolve(...)` uses pgvector. `write(...)` and `compact(...)` continue to use the legacy backend unless a future backend explicitly supports those operations. + +Local development may call `ensurePgvectorMemorySchema(...)` manually to create an empty table. Production must treat the same schema as a controlled migration with backup, approval, and a separate backfill plan. + +## API Contract + +### `resolve(input)` + +Reads memory for the current request. + +Expected input: + +```json +{ + "userId": "h5 user id", + "sessionId": "goosed session id", + "query": "current user prompt", + "limit": 40 +} +``` + +Returns a normalized payload: + +```json +{ + "ok": true, + "enabled": true, + "skipped": false, + "source": "legacy-conversation-memory", + "profile": null, + "semanticMemories": [], + "behaviorSummary": null, + "activeGoals": [], + "memories": [] +} +``` + +### `write(input)` + +Writes or queues memory-related evidence. In the legacy adapter this maps to `saveAndAnalyze(...)`. + +Expected input: + +```json +{ + "userId": "h5 user id", + "sessionId": "goosed session id", + "messages": [] +} +``` + +### `compact(input)` + +Compacts or analyzes pending user memory. In the legacy adapter this maps to `analyzeUser(...)`. + +Expected input: + +```json +{ + "userId": "h5 user id", + "sessionId": "goosed session id" +} +``` + +### `getStatus()` + +Returns the facade policy and backend contract status for read-only observability. + +Example: + +```json +{ + "enabled": true, + "backend": "legacy", + "selectedBackend": "legacy-conversation-memory", + "profileEnabled": true, + "eventLogEnabled": true, + "vectorEnabled": false, + "failOpen": true, + "backends": [ + { + "name": "legacy-conversation-memory", + "available": true, + "supports": { + "resolve": true, + "write": true, + "compact": true + } + } + ] +} +``` + +## Backend Adapter Contract + +Every backend adapter must be optional and fail-open. A backend may implement any subset of the API, but it must not create a new execution path. + +Required field: + +- `name`: stable backend name used by `MEMORY_BACKEND` + +Optional fields: + +- `isAvailable()`: returns `false` when the backend is configured but not usable +- `resolve(input)`: returns profile, semantic memories, behavior summary, active goals, or legacy memories +- `write(input)`: records or queues memory evidence +- `compact(input)`: performs pending extraction, summarization, or lifecycle maintenance + +Backend implementations must not: + +- call goosed directly +- mutate PG session state +- change SSE payloads +- block chat when unavailable +- assume they are the only memory backend + +## Plugin Registry + +Memory V2 exposes future plugin slots through read-only backend status. This is a contract registry, not a service integration. + +The default facade includes unavailable placeholders for: + +| Backend | Category | Role | +| --- | --- | --- | +| `pgvector` | semantic | primary vector store | +| `qdrant` | semantic | scale-out vector store | +| `weaviate` | semantic | knowledge graph fusion | +| `mem0` | extraction | automatic memory generation | +| `letta` | lifecycle | long/short-term memory OS | +| `neo4j` | behavior | behavior graph | +| `redis-streams` | behavior | event tracking | +| `langgraph` | policy | routing/reasoning policy | + +These placeholders: + +- report `available=false` +- expose declared capability support in `/api/runtime/status` +- never open network connections +- never create tables or queues +- never become selected while unavailable +- allow `MEMORY_BACKEND=` to safely fall back to legacy + +A real backend adapter replaces its placeholder only when the required client, credentials, schema, and tests are explicitly provided. + +Backend selection is operation-aware: + +- `resolve` may use a semantic backend such as pgvector. +- `write` must fall back to the first available backend that supports writes, usually legacy conversation memory. +- `compact` must fall back to the first available backend that supports compaction, usually legacy conversation memory. + +This allows semantic retrieval to be canaried without replacing existing memory capture or compaction. + +## Backend Contract Gate + +`memory-v2-backend-contract.mjs` defines the adapter contract gate for current and future backends. + +Every backend must: + +- have a stable lowercase kebab-case `name` +- implement at least one of `resolve`, `write`, or `compact` +- expose `isAvailable()` or default to available +- expose an unavailable reason when `isAvailable() === false` +- use a known plugin category when `category` is present +- keep feature flags in `SCREAMING_SNAKE_CASE` when `flag` is present + +The default gate checks legacy plus all disabled plugin slots: + +```bash +npm run check:memory-v2-contracts +``` + +End-to-end local validation can now be run from one entrypoint: + +```bash +npm run check:memory-v2-stack -- \ + --base-url http://127.0.0.1:18081 \ + --expect-backend pgvector \ + --expect-selected-backend pgvector +``` + +This aggregate check runs: + +- backend contract validation +- synthetic Memory V2 health validation +- app-level `/api/runtime/status` canary +- real cookie-backed `register/login -> agent/start -> agent/runs -> SSE -> remember-recent -> sync` session flow +- backend smoke probes for every configured non-legacy backend + +Unconfigured external backends are reported explicitly as `not_configured` instead of failing the whole check. + +To see exactly which env vars are still missing for any backend, run: + +```bash +npm run check:memory-v2-config -- --backend qdrant +npm run check:memory-v2-config -- --backend letta --format shell +``` + +`json` mode reports missing keys and readiness. `shell` mode prints a copy-paste export template for the selected backend. + +When replacing the pgvector placeholder with the real disabled adapter: + +```bash +npm run check:memory-v2-contracts -- --include-pgvector-adapter +``` + +Future adapters for Qdrant, Weaviate, Mem0, Letta, Neo4j, Redis Streams, or LangGraph must pass this contract before being wired into `createMemoryV2Runtime(...)`. + +## Current Validation Baseline + +As of the current Memory V2 rollout branch, local validation has already proven: + +- `memoryV2.resolve(...)` can select `pgvector` +- `memoryV2.write(...)` and `memoryV2.compact(...)` remain legacy-first +- `/user-memory/v1/remember-recent` and `/user-memory/v1/sync` still reconcile back into the active session +- the live Portal path `agent/start -> agent/runs -> SSE Finish -> session detail` continues to work under Memory V2 + +That baseline is now captured by `npm run check:memory-v2-session` and included in `npm run check:memory-v2-stack`. + +## Backend Scaffold + +`memory-v2-adapter-scaffold.mjs` renders disabled-by-default backend adapter templates that satisfy the contract gate. + +Dry-run a Qdrant adapter: + +```bash +npm run scaffold:memory-v2-backend -- \ + --name qdrant \ + --category semantic \ + --role scale-out-vector-store \ + --capability resolve +``` + +Write a Mem0 adapter scaffold: + +```bash +npm run scaffold:memory-v2-backend -- \ + --name mem0 \ + --category extraction \ + --role automatic-memory-generation \ + --capability write \ + --capability compact \ + --write +``` + +The scaffold command refuses to overwrite existing files. After generating a real adapter, run: + +```bash +npm run check:memory-v2-contracts +``` + +Then add focused adapter tests before wiring the adapter into `createMemoryV2Runtime(...)`. + +## Feature Flags + +`MEMORY_ENABLED` + +Global Memory V2 switch. If unset, it follows the existing `USER_CONVERSATION_MEMORY_ENABLED` behavior for backward compatibility. + +`MEMORY_PROFILE_ENABLED` + +Controls whether resolved profile payloads may be returned. + +`MEMORY_EVENT_LOG_ENABLED` + +Controls memory write/event capture. When disabled, `write(...)` returns a skipped result without touching the backend. + +`MEMORY_VECTOR_ENABLED` + +Reserved for future vector backends. It is disabled by default. + +`MEMORY_BACKEND` + +Preferred backend name. The current default is `legacy`. + +`MEMORY_FAIL_OPEN` + +Defaults to enabled. Backend failures return degraded empty payloads rather than blocking chat. + +`MEMORY_PGVECTOR_DATABASE_URL` + +Dedicated PostgreSQL connection string for Memory V2 semantic memory. It must not point at the MySQL business database and is ignored unless `MEMORY_VECTOR_ENABLED=1`. + +`MEMORY_PGVECTOR_EMBEDDING_MODULE` + +Optional module path for pgvector runtime retrieval. The module must export `embedQuery(query, input)` or a default async function. Without this module, runtime pgvector retrieval stays unavailable even when a PostgreSQL URL is present. + +`MEMORY_PGVECTOR_TABLE` + +Optional table name for pgvector retrieval. Defaults to `memory_embeddings`. + +`MEMORY_PGVECTOR_POOL_MAX` + +Optional PostgreSQL pool size for Memory V2 pgvector retrieval. Defaults to `5`. + +## Current Integration Points + +Memory V2 is only allowed to sit above existing memory code. + +Read path: + +```text +tkmind-proxy + -> memoryV2.resolve + -> legacy conversation-memory backend + -> existing reconcileAgentSession + -> existing buildSessionMemoryEntries + -> existing harness remember/bootstrap +``` + +Write path: + +```text +/user-memory/v1/remember-recent + -> memoryV2.write + -> legacy conversation-memory saveAndAnalyze +``` + +Compact path: + +```text +/user-memory/v1/sync + -> memoryV2.compact + -> legacy conversation-memory analyzeUser +``` + +If `MEMORY_ENABLED=0`, Memory V2 returns skipped/empty results and does not touch the legacy backend. The session reconcile path still runs for non-memory context such as sandbox guidance and time anchors. + +Runtime observability: + +```text +/api/runtime/status + -> memory: memoryV2.getStatus() +``` + +This is read-only and exists only to make rollout/debugging visible. + +Local health gate: + +```bash +npm run check:memory-v2-contracts +npm run check:memory-v2 -- --require-enabled --expect-backend legacy +``` + +Local app canary against a running Portal service: + +```bash +npm run canary:memory-v2-app -- \ + --base-url http://127.0.0.1:8081 \ + --require-enabled \ + --expect-backend legacy +``` + +For pgvector canary, run the app with explicit Memory V2 env and assert both configured and selected backends: + +```bash +npm run canary:memory-v2-app -- \ + --base-url http://127.0.0.1:18081 \ + --require-enabled \ + --require-target-healthy \ + --expect-backend pgvector \ + --expect-selected-backend pgvector +``` + +Production rollout details live in [production-rollout-runbook.md](production-rollout-runbook.md). + +## Backend Plugin Slots + +These are optional future backends. None is required for Memory V2 phase one. + +Semantic memory: + +- pgvector as the primary vector option +- Qdrant as a scale-out option +- Weaviate for knowledge graph fusion + +Memory extraction: + +- Mem0 for automatic memory generation + +Memory lifecycle management: + +- Letta for long-term and short-term memory operating semantics + +Behavior and user model: + +- Neo4j for behavior graph modeling +- Redis Streams for event tracking + +Memory policy engine: + +- LangGraph for routing and reasoning policy + +## Integration Rule + +Future integration must use existing Memind injection points. The preferred path is: + +```text +tkmind-proxy / agent run + -> Memory V2 resolve + -> existing session reconcile + -> existing buildSessionMemoryEntries + -> existing harness remember/bootstrap +``` + +Do not add direct goosed changes, PG session changes, SSE protocol changes, or new blocking calls in the reply path. + +## Rollout Checklist + +Phase 1 rollout: + +1. Keep `MEMORY_BACKEND=legacy`. +2. Enable `MEMORY_ENABLED=1`. +3. Keep `MEMORY_VECTOR_ENABLED=0`. +4. Verify `/api/runtime/status` reports `memory.enabled=true` and `selectedBackend=legacy-conversation-memory`. +5. Run targeted tests before release. + +Rollback: + +```bash +MEMORY_ENABLED=0 +``` + +Rollback must stop Memory V2 reads/writes while preserving non-memory session reconcile behavior. + +Future backend rollout: + +1. Add the backend adapter behind `MEMORY_BACKEND=`. +2. Replace the matching unavailable placeholder with the real adapter only inside Memory V2 wiring. +3. Verify `getStatus().backends[]` reports capability support. +4. Keep backend disabled or unavailable by default. +5. Run local retrieval/write tests without changing goosed/SSE. +6. Enable for a narrow canary only after legacy fallback remains green. + +## pgvector Adapter Skeleton + +`memory-v2-pgvector.mjs` is the first semantic-memory backend skeleton. + +Current constraints: + +- Disabled by default. +- Requires an injected PostgreSQL pool with a `query(sql, params)` method. +- Requires an explicit embedding via `resolve({ embedding })` or an injected `embedQuery(query, input)` function. +- Server runtime only creates the PostgreSQL pool when vector retrieval, a PostgreSQL URL, and an embedding module are all configured. +- Performs read-only vector lookup. +- Does not create schema automatically. +- Does not migrate or copy MySQL memory rows. +- Does not call goosed, SSE, or Portal execution APIs. +- Falls back through Memory V2 when unavailable. + +Manual local schema helper: + +```js +import { ensurePgvectorMemorySchema } from './memory-v2-pgvector-schema.mjs'; + +await ensurePgvectorMemorySchema(pool, { + tableName: 'memory_embeddings', + dimensions: 1536, + createExtension: true, + createVectorIndex: false, +}); +``` + +Dry-run CLI: + +```bash +node scripts/setup-memory-v2-pgvector-schema.mjs \ + --table memory_embeddings \ + --dimensions 1536 \ + --create-extension +``` + +Apply CLI: + +```bash +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +node scripts/setup-memory-v2-pgvector-schema.mjs \ + --apply \ + --table memory_embeddings \ + --dimensions 1536 \ + --create-extension +``` + +The CLI defaults to dry-run and never reads the existing MySQL `DATABASE_URL`. `--apply` requires an explicit PostgreSQL URL environment variable. + +Expected table shape: + +```sql +CREATE TABLE memory_embeddings ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + embedding VECTOR(1536) NOT NULL, + type TEXT NOT NULL DEFAULT 'fact', + source_memory_id TEXT, + source_session_id TEXT, + source_message_id TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +Rollout rule: + +Keep `MEMORY_BACKEND=legacy` until pgvector schema, embedding generation, backfill, and canary verification are designed separately. + +Production migration considerations: + +1. Confirm pgvector is installed on the target PostgreSQL server. +2. Run `CREATE EXTENSION IF NOT EXISTS vector` only through the approved DB migration path. +3. Create the empty `memory_embeddings` table first; do not backfill in the same release. +4. Keep `MEMORY_BACKEND=legacy` after table creation. +5. Build a separate backfill job from `h5_user_memory_items` into pgvector with idempotent checkpoints. +6. Verify row counts, embedding dimensions, and retrieval quality before canarying `MEMORY_BACKEND=pgvector`. +7. Rollback for the app remains `MEMORY_BACKEND=legacy` or `MEMORY_ENABLED=0`; database rollback should not delete populated memory rows without an explicit retention decision. + +## pgvector Backfill Skeleton + +`memory-v2-pgvector-backfill.mjs` defines the future MySQL-to-pgvector backfill contract. + +Source: + +```text +h5_user_memory_items + WHERE status = 'active' + ORDER BY updated_at ASC, id ASC +``` + +Destination: + +```text +memory_embeddings.source_memory_id = h5_user_memory_items.id +``` + +The `source_memory_id` unique index makes backfill idempotent. Re-running the same batch updates content, embedding, type, source pointers, metadata, and `updated_at`. + +Current constraints: + +- Defaults to dry-run. +- Dry-run reads MySQL only and does not call embedding or PostgreSQL. +- Apply requires `pgPool.query(...)`. +- Apply requires an explicit `embedMemory(memory) => number[]`. +- Backfill is paginated with `{ updatedAt, id }` checkpoints. +- Backfill does not change `MEMORY_BACKEND`. +- Backfill does not create schema; run schema setup separately first. + +Dry-run CLI: + +```bash +MEMORY_BACKFILL_MYSQL_URL='mysql://...' \ +node scripts/backfill-memory-v2-pgvector.mjs \ + --limit 100 \ + --cursor-updated-at 0 \ + --cursor-id '' +``` + +Apply CLI: + +```bash +MEMORY_BACKFILL_MYSQL_URL='mysql://...' \ +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +node scripts/backfill-memory-v2-pgvector.mjs \ + --apply \ + --limit 100 \ + --embedding-module ./scripts/embed-memory-v2-pgvector.mjs +``` + +The apply command requires the embedding module to export `embedMemory(memory)` or a default function. The CLI intentionally uses `MEMORY_BACKFILL_MYSQL_URL` instead of the app's normal MySQL env so production backfills are explicit and auditable. + +Production backfill sequence: + +1. Run schema migration and keep `MEMORY_BACKEND=legacy`. +2. Run dry-run batches and inspect counts/checkpoints. +3. Run apply batches with a fixed embedding model and recorded dimensions. +4. Store the last checkpoint externally after each successful batch. +5. Compare MySQL active memory count against pgvector distinct `source_memory_id` count. +6. Validate retrieval quality on a canary user. +7. Only then consider `MEMORY_BACKEND=pgvector` canary. + +## pgvector Smoke Test + +`memory-v2-pgvector-smoke.mjs` and `scripts/smoke-memory-v2-pgvector.mjs` provide a local pgvector read/write smoke test. + +The smoke test: + +- uses synthetic `memory-v2-smoke-*` rows only +- inserts deterministic vectors +- verifies the nearest vector is returned first through the Memory V2 pgvector adapter +- cleans synthetic rows by default +- never runs from app startup +- never reads the MySQL `DATABASE_URL` + +Existing-schema smoke: + +```bash +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +npm run smoke:memory-v2-pgvector +``` + +Local setup smoke that also creates an empty schema: + +```bash +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +npm run smoke:memory-v2-pgvector -- \ + --create-schema \ + --create-extension \ + --table memory_embeddings \ + --dimensions 3 +``` + +Production usage is limited to post-migration verification. Do not use the smoke script as a migration mechanism unless the migration plan explicitly approves `--create-schema`. + +## Local Canary Env + +`docs/memory-v2/local-canary.env.example` contains copyable environment examples for: + +- legacy no-op facade canary +- pgvector local semantic canary +- Qdrant and Weaviate read-only canaries +- Mem0, Letta, Neo4j, Redis Streams, and LangGraph external canaries + +For local semantic smoke only, `scripts/embed-memory-v2-local-hash.mjs` exports a deterministic hash embedding function: + +```bash +MEMORY_PGVECTOR_EMBEDDING_MODULE=./scripts/embed-memory-v2-local-hash.mjs +MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS=3 +``` + +This module is intentionally simple and deterministic. It is useful for local plumbing checks, but not for production retrieval quality. + +## Qdrant Read-Only Adapter + +`memory-v2-qdrant.mjs` is the first external plugin adapter beyond pgvector. + +Current constraints: + +- No Qdrant SDK is imported. +- The adapter uses a minimal read-only HTTP client wrapper. +- It only calls Qdrant search when `MEMORY_QDRANT_ENABLED=1`, `MEMORY_QDRANT_URL`, and `MEMORY_QDRANT_EMBEDDING_MODULE` are configured. +- It never creates collections. +- It never writes or upserts points. +- `write(...)` and `compact(...)` continue to use legacy memory. + +Runtime read-only configuration: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=qdrant +MEMORY_QDRANT_ENABLED=1 +MEMORY_QDRANT_URL='http://127.0.0.1:6333' +MEMORY_QDRANT_COLLECTION='memind_memory' +MEMORY_QDRANT_EMBEDDING_MODULE='./scripts/embed-memory-v2-qdrant.mjs' +``` + +Read-only smoke: + +```bash +MEMORY_QDRANT_URL='http://127.0.0.1:6333' \ +npm run smoke:memory-v2-qdrant -- \ + --collection memind_memory \ + --vector 1,0,0 \ + --limit 1 +``` + +Expected without embedding module: + +```text +selectedBackend=legacy-conversation-memory +qdrant.available=false +qdrant.reason=embedding_module_not_configured +``` + +Expected with embedding module and reachable Qdrant: + +```text +selectedBackend=qdrant +write_uses_legacy=true +compact_uses_legacy=true +``` + +## Mem0 Extraction Adapter + +`memory-v2-mem0.mjs` defines the Memory Extraction backend boundary. + +Current constraints: + +- No Mem0 SDK is imported. +- Runtime uses a lightweight HTTP client only when `MEMORY_MEM0_ENABLED=1` and `MEMORY_MEM0_API_KEY` are configured. +- HTTP paths are configurable with `MEMORY_MEM0_WRITE_PATH` and `MEMORY_MEM0_COMPACT_PATH`. +- Without a full configuration, status reports `mem0.available=false` with a concrete reason and Memory V2 falls back to legacy. +- `MEMORY_BACKEND=mem0` does not affect `resolve(...)`. +- `write(...)` and `compact(...)` can use Mem0 when explicitly selected; legacy remains the default and rollback path. + +Runtime configuration: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=mem0 +MEMORY_MEM0_ENABLED=1 +MEMORY_MEM0_API_KEY='...' +MEMORY_MEM0_PROJECT_ID='memind_project' +MEMORY_MEM0_BASE_URL='https://api.mem0.ai' +``` + +Smoke: + +```bash +npm run smoke:memory-v2-external -- --backend mem0 --operation write +``` + +## Letta Lifecycle Adapter + +`memory-v2-letta.mjs` defines the Memory Lifecycle Management backend boundary. + +Current constraints: + +- No Letta SDK is imported. +- Runtime uses a lightweight HTTP client only when `MEMORY_LETTA_ENABLED=1`, `MEMORY_LETTA_API_KEY`, and `MEMORY_LETTA_AGENT_ID` are configured. +- HTTP paths are configurable with `MEMORY_LETTA_RESOLVE_PATH`, `MEMORY_LETTA_WRITE_PATH`, and `MEMORY_LETTA_COMPACT_PATH`. +- Without a full configuration, status reports `letta.available=false` with a concrete reason and Memory V2 falls back to legacy. +- `resolve(...)`, `write(...)`, and `compact(...)` can use Letta when explicitly selected. + +Runtime configuration: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=letta +MEMORY_LETTA_ENABLED=1 +MEMORY_LETTA_API_KEY='...' +MEMORY_LETTA_PROJECT_ID='memind_project' +MEMORY_LETTA_AGENT_ID='agent_1' +MEMORY_LETTA_BASE_URL='https://api.letta.com' +``` + +Smoke: + +```bash +npm run smoke:memory-v2-external -- --backend letta --operation resolve +``` + +Do not enable Letta as the selected backend in production until sampling, retention, conflict resolution, and rollback rules are defined for long/short-term lifecycle ownership. + +## Remaining External Adapters + +The remaining optional backends now have runtime-wired clients: + +| Backend | File | Category | Runtime role | +|---|---|---|---| +| Weaviate | `memory-v2-weaviate.mjs` | semantic | GraphQL vector search | +| Neo4j | `memory-v2-neo4j.mjs` | behavior | HTTP transaction behavior graph | +| Redis Streams | `memory-v2-redis-streams.mjs` | behavior | Redis `XADD` event tracking | +| LangGraph | `memory-v2-langgraph.mjs` | policy | HTTP policy resolve | + +Current constraints: + +- Weaviate and LangGraph use lightweight HTTP clients. +- Neo4j uses the HTTP transactional endpoint; `neo4j-driver` is not introduced. +- Redis Streams uses the existing `redis` dependency. +- No default runtime path opens network connections; each adapter is dormant unless its feature flag and required config are present. +- If configured incompletely, each adapter reports `available=false` with a concrete reason and Memory V2 falls back to legacy. +- `redis-streams` is write-only and cannot affect `resolve(...)`. + +Runtime flags: + +```bash +MEMORY_WEAVIATE_ENABLED=1 +MEMORY_WEAVIATE_URL='https://weaviate.example' +MEMORY_WEAVIATE_COLLECTION='MemindMemory' +MEMORY_WEAVIATE_EMBEDDING_MODULE='./scripts/embed-memory-v2-weaviate.mjs' + +MEMORY_NEO4J_ENABLED=1 +MEMORY_NEO4J_HTTP_URL='http://localhost:7474' +MEMORY_NEO4J_USER='neo4j' +MEMORY_NEO4J_PASSWORD='...' +MEMORY_NEO4J_DATABASE='neo4j' + +MEMORY_REDIS_STREAMS_ENABLED=1 +MEMORY_REDIS_STREAMS_URL='redis://localhost:6379' +MEMORY_REDIS_STREAMS_STREAM='memind:memory-events' + +MEMORY_LANGGRAPH_ENABLED=1 +MEMORY_LANGGRAPH_URL='https://langgraph.example' +MEMORY_LANGGRAPH_POLICY_ID='memory_policy' +``` + +Smoke examples: + +```bash +npm run smoke:memory-v2-external -- --backend weaviate --operation resolve +npm run smoke:memory-v2-external -- --backend neo4j --operation write +npm run smoke:memory-v2-external -- --backend redis-streams --operation write +npm run smoke:memory-v2-external -- --backend langgraph --operation resolve +``` + +Future production canaries must define per-backend ownership boundaries before any of these adapters can take over `resolve(...)` or `write(...)`. + +## Verification + +Targeted regression command: + +```bash +node --test memory-v2.test.mjs conversation-memory.test.mjs user-memory-profile.test.mjs tkmind-proxy.test.mjs +node --test memory-v2-adapter-scaffold.test.mjs +node --test memory-v2-backend-contract.test.mjs +node --test memory-v2-health.test.mjs +node --test memory-v2-runtime.test.mjs +node --test memory-v2-plugin-backends.test.mjs +node --test memory-v2-pgvector.test.mjs +node --test memory-v2-pgvector-schema.test.mjs +node --test memory-v2-pgvector-backfill.test.mjs +node --test memory-v2-pgvector-smoke.test.mjs +node --test memory-v2-qdrant.test.mjs +node --test memory-v2-mem0.test.mjs +node --test memory-v2-letta.test.mjs +node --test memory-v2-external-adapters.test.mjs +node --test scripts/check-memory-v2-contracts.test.mjs +node --test scripts/check-memory-v2-health.test.mjs +node --test scripts/setup-memory-v2-pgvector-schema.test.mjs +node --test scripts/backfill-memory-v2-pgvector.test.mjs +node --test scripts/scaffold-memory-v2-backend.test.mjs +node --test scripts/smoke-memory-v2-pgvector.test.mjs +node --test scripts/smoke-memory-v2-qdrant.test.mjs +node --test scripts/smoke-memory-v2-external.test.mjs +node --check server.mjs +``` + +The targeted tests cover: + +- legacy backend adaptation +- backend adapter scaffold generation +- backend adapter contract gate +- local Memory V2 health gate checks +- runtime pgvector wiring only when explicitly configured +- configured backend selection and fallback +- future plugin slot visibility without runtime selection +- profile suppression via policy +- fail-open resolve behavior +- event-log write gating +- session start memory injection through Memory V2 +- disabled Memory V2 not touching legacy memory or injecting stored memories +- runtime status exposing Memory V2 policy and backend contract +- reply path passing the current user prompt into `resolve(...)` while keeping the existing `/reply` execution path +- pgvector adapter disabled-by-default behavior and parameterized semantic lookup +- Qdrant read-only HTTP client, smoke CLI, and fallback behavior +- Mem0 extraction HTTP client and runtime fallback behavior +- Letta lifecycle HTTP client and runtime fallback behavior +- Weaviate, Neo4j, Redis Streams, and LangGraph runtime client behavior +- manual pgvector schema SQL generation and execution ordering +- pgvector schema CLI dry-run and explicit apply guard +- pgvector backfill dry-run, checkpoint pagination, and idempotent upsert contract +- pgvector backfill CLI dry-run and explicit apply guard +- pgvector local smoke fixture and CLI guards diff --git a/docs/memory-v2/local-canary.env.example b/docs/memory-v2/local-canary.env.example new file mode 100644 index 0000000..f99dd6b --- /dev/null +++ b/docs/memory-v2/local-canary.env.example @@ -0,0 +1,69 @@ +# Memory V2 local canary example. +# Copy the variables you need into your local shell or .env. +# Do not use the local hash embedding module for production retrieval quality. + +MEMORY_ENABLED=1 +MEMORY_FAIL_OPEN=1 + +# Legacy-safe default canary. +MEMORY_BACKEND=legacy + +# pgvector local canary. +# MEMORY_BACKEND=pgvector +# MEMORY_VECTOR_ENABLED=1 +# Homebrew PostgreSQL local default on John's machine: +# MEMORY_PGVECTOR_DATABASE_URL=postgresql://john@127.0.0.1:5432/memind_memory +# Password-based PostgreSQL example: +# MEMORY_PGVECTOR_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/memind_memory +# MEMORY_PGVECTOR_TABLE=memory_embeddings +# MEMORY_PGVECTOR_EMBEDDING_MODULE=./scripts/embed-memory-v2-local-hash.mjs +# MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS=3 + +# Qdrant read-only canary. +# MEMORY_BACKEND=qdrant +# MEMORY_QDRANT_ENABLED=1 +# MEMORY_QDRANT_URL=http://127.0.0.1:6333 +# MEMORY_QDRANT_COLLECTION=memind_memory +# MEMORY_QDRANT_EMBEDDING_MODULE=./scripts/embed-memory-v2-local-hash.mjs + +# Weaviate read-only canary. +# MEMORY_BACKEND=weaviate +# MEMORY_WEAVIATE_ENABLED=1 +# MEMORY_WEAVIATE_URL=http://127.0.0.1:8080 +# MEMORY_WEAVIATE_COLLECTION=MemindMemory +# MEMORY_WEAVIATE_EMBEDDING_MODULE=./scripts/embed-memory-v2-local-hash.mjs + +# Mem0 write/compact canary. +# MEMORY_BACKEND=mem0 +# MEMORY_MEM0_ENABLED=1 +# MEMORY_MEM0_API_KEY=replace-me +# MEMORY_MEM0_PROJECT_ID=memind_project +# MEMORY_MEM0_BASE_URL=https://api.mem0.ai + +# Letta lifecycle canary. +# MEMORY_BACKEND=letta +# MEMORY_LETTA_ENABLED=1 +# MEMORY_LETTA_API_KEY=replace-me +# MEMORY_LETTA_AGENT_ID=agent_1 +# MEMORY_LETTA_PROJECT_ID=memind_project +# MEMORY_LETTA_BASE_URL=https://api.letta.com + +# Neo4j behavior graph canary. +# MEMORY_BACKEND=neo4j +# MEMORY_NEO4J_ENABLED=1 +# MEMORY_NEO4J_HTTP_URL=http://127.0.0.1:7474 +# MEMORY_NEO4J_USER=neo4j +# MEMORY_NEO4J_PASSWORD=replace-me +# MEMORY_NEO4J_DATABASE=neo4j + +# Redis Streams event canary. +# MEMORY_BACKEND=redis-streams +# MEMORY_REDIS_STREAMS_ENABLED=1 +# MEMORY_REDIS_STREAMS_URL=redis://127.0.0.1:6379 +# MEMORY_REDIS_STREAMS_STREAM=memind:memory-events + +# LangGraph policy canary. +# MEMORY_BACKEND=langgraph +# MEMORY_LANGGRAPH_ENABLED=1 +# MEMORY_LANGGRAPH_URL=http://127.0.0.1:2024 +# MEMORY_LANGGRAPH_POLICY_ID=memory_policy diff --git a/docs/memory-v2/production-rollout-runbook.md b/docs/memory-v2/production-rollout-runbook.md new file mode 100644 index 0000000..4106707 --- /dev/null +++ b/docs/memory-v2/production-rollout-runbook.md @@ -0,0 +1,335 @@ +# Memory V2 Production Rollout Runbook + +Memory V2 is a facade and policy layer. Production rollout must not introduce new goosed, PG session, SSE, or Portal execution paths. + +## Release Gates + +Run the normal repository release checks first: + +```bash +bash scripts/check-release-ready.sh +``` + +Run the Memory V2 backend contract gate: + +```bash +npm run check:memory-v2-contracts +``` + +Run the Memory V2 health gate: + +```bash +npm run check:memory-v2 -- --require-enabled --expect-backend legacy +``` + +Expected phase-one result: + +- `ok=true` +- contract gate reports unique backend names and legacy backend present +- `summary.enabled=true` +- `summary.backend=legacy` +- `summary.selectedBackend=legacy-conversation-memory` +- `fail_open` passes +- `legacy_write_compact_supported` passes +- `write_uses_legacy` passes +- `compact_uses_legacy` passes + +When validating the real pgvector adapter before canary: + +```bash +npm run check:memory-v2-contracts -- --include-pgvector-adapter +``` + +Future backend adapter rule: + +```bash +npm run scaffold:memory-v2-backend -- \ + --name qdrant \ + --category semantic \ + --capability resolve +``` + +Use the scaffold as the starting point, add focused adapter tests, then pass `npm run check:memory-v2-contracts` before wiring any external SDK into runtime. Do not wire external services directly into `server.mjs`, `tkmind-proxy.mjs`, goosed, SSE, or Portal execution code. + +## Qdrant Read-Only Phase + +The Qdrant adapter may be visible in runtime status before user traffic is canaried. + +Fallback-only environment: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=qdrant +MEMORY_QDRANT_ENABLED=1 +MEMORY_QDRANT_URL='http://qdrant.example:6333' +MEMORY_QDRANT_COLLECTION='memind_memory' +``` + +Expected without embedding module: + +- `memory.backend=qdrant` +- `memory.selectedBackend=legacy-conversation-memory` +- `qdrant.available=false` +- `qdrant.reason=embedding_module_not_configured` +- `write_uses_legacy` still passes +- `compact_uses_legacy` still passes + +Read-only smoke: + +```bash +MEMORY_QDRANT_URL='http://qdrant.example:6333' \ +npm run smoke:memory-v2-qdrant -- \ + --collection memind_memory \ + --vector 1,0,0 \ + --limit 1 +``` + +The smoke command sends a Qdrant search request only. It must not create collections, write points, or run from app startup. + +Runtime read-only canary requires: + +```bash +MEMORY_QDRANT_EMBEDDING_MODULE='./scripts/embed-memory-v2-qdrant.mjs' +``` + +Even during read-only canary, `write_uses_legacy` and `compact_uses_legacy` must still pass. + +## Mem0 Adapter Phase + +Mem0 is the Memory Extraction backend boundary. The adapter uses HTTP and is dormant unless explicitly configured. + +Environment: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=mem0 +MEMORY_MEM0_ENABLED=1 +MEMORY_MEM0_API_KEY='...' +MEMORY_MEM0_PROJECT_ID='memind_project' +MEMORY_MEM0_BASE_URL='https://api.mem0.ai' +``` + +Smoke: + +```bash +npm run smoke:memory-v2-external -- --backend mem0 --operation write +``` + +Do not canary Mem0 writes until sampling, dedupe, rollback, and retention rules are accepted. + +## Letta Lifecycle Adapter Phase + +Letta is the Memory Lifecycle Management backend boundary. The adapter uses HTTP and is dormant unless explicitly configured. + +Environment: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=letta +MEMORY_LETTA_ENABLED=1 +MEMORY_LETTA_API_KEY='...' +MEMORY_LETTA_PROJECT_ID='memind_project' +MEMORY_LETTA_AGENT_ID='agent_1' +MEMORY_LETTA_BASE_URL='https://api.letta.com' +``` + +Smoke: + +```bash +npm run smoke:memory-v2-external -- --backend letta --operation resolve +``` + +Do not canary Letta until ownership boundaries, long/short-term promotion rules, conflict resolution with conversation-memory, rollback, and retention are accepted. + +## Remaining External Adapter Phase + +Weaviate, Neo4j, Redis Streams, and LangGraph are wired with runtime clients and remain dormant unless explicitly configured. + +Environment examples: + +```bash +MEMORY_WEAVIATE_ENABLED=1 +MEMORY_WEAVIATE_URL='https://weaviate.example' +MEMORY_WEAVIATE_COLLECTION='MemindMemory' +MEMORY_WEAVIATE_EMBEDDING_MODULE='./scripts/embed-memory-v2-weaviate.mjs' + +MEMORY_NEO4J_ENABLED=1 +MEMORY_NEO4J_HTTP_URL='http://localhost:7474' +MEMORY_NEO4J_USER='neo4j' +MEMORY_NEO4J_PASSWORD='...' + +MEMORY_REDIS_STREAMS_ENABLED=1 +MEMORY_REDIS_STREAMS_URL='redis://localhost:6379' + +MEMORY_LANGGRAPH_ENABLED=1 +MEMORY_LANGGRAPH_URL='https://langgraph.example' +MEMORY_LANGGRAPH_POLICY_ID='memory_policy' +``` + +Smoke: + +```bash +npm run smoke:memory-v2-external -- --backend weaviate --operation resolve +npm run smoke:memory-v2-external -- --backend neo4j --operation write +npm run smoke:memory-v2-external -- --backend redis-streams --operation write +npm run smoke:memory-v2-external -- --backend langgraph --operation resolve +``` + +Future canaries must be backend-specific and must define data ownership, dedupe, observability, rollback, and retention before any adapter can own a Memory V2 operation. + +## Phase 1: Facade Over Legacy + +Environment: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=legacy +MEMORY_FAIL_OPEN=1 +MEMORY_VECTOR_ENABLED=0 +``` + +Runtime verification: + +```bash +curl -sk https:///api/runtime/status +``` + +Confirm: + +- `memory.enabled=true` +- `memory.backend=legacy` +- `memory.selectedBackend=legacy-conversation-memory` +- future plugin slots report `available=false` + +Rollback: + +```bash +MEMORY_ENABLED=0 +``` + +## Phase 2: Empty pgvector Schema + +This phase creates schema only. It must not change runtime backend selection. + +Preconditions: + +- PostgreSQL server is approved for Memory V2 semantic memory. +- `pgvector` extension is available. +- Backup/rollback decision is recorded by ops. + +Dry-run: + +```bash +node scripts/setup-memory-v2-pgvector-schema.mjs \ + --table memory_embeddings \ + --dimensions 1536 \ + --create-extension +``` + +Apply only through the approved production migration path: + +```bash +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +node scripts/setup-memory-v2-pgvector-schema.mjs \ + --apply \ + --table memory_embeddings \ + --dimensions 1536 \ + --create-extension +``` + +Keep runtime on legacy: + +```bash +MEMORY_BACKEND=legacy +MEMORY_VECTOR_ENABLED=0 +``` + +Post-migration smoke verification: + +```bash +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +npm run smoke:memory-v2-pgvector -- \ + --table memory_embeddings \ + --dimensions 1536 +``` + +The smoke command inserts synthetic `memory-v2-smoke-*` rows, verifies nearest-vector retrieval, and deletes those rows by default. In production it should run only after the schema migration. Do not pass `--create-schema` in production unless the approved migration procedure explicitly requires it. + +For local-only plumbing checks, use the deterministic hash embedding module: + +```bash +MEMORY_PGVECTOR_EMBEDDING_MODULE=./scripts/embed-memory-v2-local-hash.mjs +MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS=3 +``` + +Do not use this local hash embedding module for production retrieval quality. + +## Phase 3: Backfill + +Backfill must be separate from schema creation and application rollout. + +Dry-run: + +```bash +MEMORY_BACKFILL_MYSQL_URL='mysql://...' \ +node scripts/backfill-memory-v2-pgvector.mjs --limit 100 +``` + +Apply: + +```bash +MEMORY_BACKFILL_MYSQL_URL='mysql://...' \ +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' \ +node scripts/backfill-memory-v2-pgvector.mjs \ + --apply \ + --limit 100 \ + --embedding-module ./scripts/embed-memory-v2-pgvector.mjs +``` + +Operational rules: + +- Store `{ updatedAt, id }` checkpoint externally after each successful batch. +- Compare active MySQL memory count with pgvector distinct `source_memory_id` count. +- Do not change `MEMORY_BACKEND` during backfill. +- Do not delete pgvector rows during app rollback without an explicit retention decision. + +## Phase 4: pgvector Resolve Canary + +Only start canary after schema, backfill, and retrieval quality checks pass. + +Environment: + +```bash +MEMORY_ENABLED=1 +MEMORY_BACKEND=pgvector +MEMORY_VECTOR_ENABLED=1 +MEMORY_FAIL_OPEN=1 +MEMORY_PGVECTOR_DATABASE_URL='postgresql://...' +MEMORY_PGVECTOR_EMBEDDING_MODULE='./scripts/embed-memory-v2-pgvector.mjs' +``` + +Health gate: + +```bash +npm run check:memory-v2 -- --require-enabled --expect-backend pgvector +``` + +Required invariant: + +- `selectedBackend=pgvector` is allowed for `resolve`. +- `write_uses_legacy` must still pass. +- `compact_uses_legacy` must still pass. +- Any pgvector error must fail open to legacy or degraded empty resolve. + +Rollback: + +```bash +MEMORY_BACKEND=legacy +MEMORY_VECTOR_ENABLED=0 +``` + +Emergency rollback: + +```bash +MEMORY_ENABLED=0 +``` diff --git a/memory-v2-adapter-scaffold.mjs b/memory-v2-adapter-scaffold.mjs new file mode 100644 index 0000000..7f1011d --- /dev/null +++ b/memory-v2-adapter-scaffold.mjs @@ -0,0 +1,84 @@ +const VALID_CATEGORIES = new Set(['semantic', 'extraction', 'lifecycle', 'behavior', 'policy']); +const VALID_CAPABILITIES = new Set(['resolve', 'write', 'compact']); + +function kebabCase(value) { + return String(value ?? '') + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +function screamingSnake(value) { + return String(value ?? '') + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toUpperCase(); +} + +function normalizeCapabilities(capabilities) { + const normalized = [...new Set( + (Array.isArray(capabilities) ? capabilities : String(capabilities ?? '').split(',')) + .map((item) => String(item).trim()) + .filter(Boolean), + )]; + if (normalized.length === 0) throw new Error('At least one capability is required'); + for (const capability of normalized) { + if (!VALID_CAPABILITIES.has(capability)) { + throw new Error(`Unsupported Memory V2 capability: ${capability}`); + } + } + return normalized; +} + +export function normalizeMemoryV2AdapterScaffoldOptions({ + name, + category, + role = 'optional-plugin', + capabilities, + flag = null, +} = {}) { + const normalizedName = kebabCase(name); + if (!normalizedName) throw new Error('Adapter name is required'); + const normalizedCategory = String(category ?? '').trim(); + if (!VALID_CATEGORIES.has(normalizedCategory)) { + throw new Error(`Adapter category must be one of: ${[...VALID_CATEGORIES].join(', ')}`); + } + const normalizedCapabilities = normalizeCapabilities(capabilities); + const normalizedFlag = flag + ? screamingSnake(flag) + : `MEMORY_${screamingSnake(normalizedName)}_ENABLED`; + return { + name: normalizedName, + factoryName: `create${normalizedName + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('')}MemoryBackend`, + category: normalizedCategory, + role: kebabCase(role) || 'optional-plugin', + capabilities: normalizedCapabilities, + flag: normalizedFlag, + }; +} + +function operationMethod(operation) { + if (operation === 'resolve') { + return `\n async resolve() {\n return {\n memories: [],\n semanticMemories: [],\n behaviorSummary: null,\n activeGoals: [],\n };\n },`; + } + if (operation === 'write') { + return `\n async write() {\n return { saved: 0, analyzed: 0, memories: 0 };\n },`; + } + if (operation === 'compact') { + return `\n async compact() {\n return { analyzed: 0, memories: 0 };\n },`; + } + return ''; +} + +export function renderMemoryV2AdapterScaffold(options = {}) { + const normalized = normalizeMemoryV2AdapterScaffoldOptions(options); + const methods = normalized.capabilities.map((capability) => operationMethod(capability)).join('\n'); + return `// Generated Memory V2 backend scaffold.\n// Keep disabled by default. Wire real clients only through memory-v2-runtime.mjs.\n\nexport function ${normalized.factoryName}({ enabled = false, unavailableReason = 'not_configured' } = {}) {\n return {\n name: '${normalized.name}',\n category: '${normalized.category}',\n role: '${normalized.role}',\n flag: '${normalized.flag}',\n unavailableReason,\n\n isAvailable() {\n return Boolean(enabled);\n },\n\n getUnavailableReason() {\n return this.isAvailable() ? null : unavailableReason;\n },${methods}\n };\n}\n`; +} diff --git a/memory-v2-adapter-scaffold.test.mjs b/memory-v2-adapter-scaffold.test.mjs new file mode 100644 index 0000000..cb7e18b --- /dev/null +++ b/memory-v2-adapter-scaffold.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import { + normalizeMemoryV2AdapterScaffoldOptions, + renderMemoryV2AdapterScaffold, +} from './memory-v2-adapter-scaffold.mjs'; +import { inspectMemoryV2Backend } from './memory-v2-backend-contract.mjs'; + +async function loadGeneratedBackend(source, factoryName) { + const transformed = `${source.replace(/export function /, 'function ')}\n${factoryName};`; + const script = new vm.Script(transformed); + const factory = script.runInNewContext({}); + return factory(); +} + +test('normalizeMemoryV2AdapterScaffoldOptions normalizes names, flags, and capabilities', () => { + assert.deepEqual( + normalizeMemoryV2AdapterScaffoldOptions({ + name: 'Qdrant Store', + category: 'semantic', + role: 'Scale Out Vector Store', + capabilities: ['resolve', 'resolve'], + }), + { + name: 'qdrant-store', + factoryName: 'createQdrantStoreMemoryBackend', + category: 'semantic', + role: 'scale-out-vector-store', + capabilities: ['resolve'], + flag: 'MEMORY_QDRANT_STORE_ENABLED', + }, + ); +}); + +test('normalizeMemoryV2AdapterScaffoldOptions rejects invalid category and capability', () => { + assert.throws( + () => normalizeMemoryV2AdapterScaffoldOptions({ + name: 'bad', + category: 'unknown', + capabilities: ['resolve'], + }), + /Adapter category/, + ); + assert.throws( + () => normalizeMemoryV2AdapterScaffoldOptions({ + name: 'bad', + category: 'semantic', + capabilities: ['delete'], + }), + /Unsupported Memory V2 capability/, + ); +}); + +test('renderMemoryV2AdapterScaffold generates disabled backend that passes contract gate', async () => { + const options = normalizeMemoryV2AdapterScaffoldOptions({ + name: 'qdrant', + category: 'semantic', + role: 'scale-out-vector-store', + capabilities: ['resolve'], + flag: 'MEMORY_QDRANT_ENABLED', + }); + const source = renderMemoryV2AdapterScaffold(options); + const backend = await loadGeneratedBackend(source, options.factoryName); + const report = inspectMemoryV2Backend(backend); + + assert.match(source, /export function createQdrantMemoryBackend/); + assert.equal(backend.isAvailable(), false); + assert.equal(backend.getUnavailableReason(), 'not_configured'); + assert.equal(report.ok, true); + assert.deepEqual(report.supports, { + resolve: true, + write: false, + compact: false, + }); +}); + +test('renderMemoryV2AdapterScaffold can include write and compact operations', async () => { + const options = normalizeMemoryV2AdapterScaffoldOptions({ + name: 'mem0', + category: 'extraction', + capabilities: ['write', 'compact'], + }); + const backend = await loadGeneratedBackend( + renderMemoryV2AdapterScaffold(options), + options.factoryName, + ); + + assert.deepEqual(JSON.parse(JSON.stringify(await backend.write({}))), { + saved: 0, + analyzed: 0, + memories: 0, + }); + assert.deepEqual(JSON.parse(JSON.stringify(await backend.compact({}))), { + analyzed: 0, + memories: 0, + }); + assert.equal(inspectMemoryV2Backend(backend).ok, true); +}); diff --git a/memory-v2-admin-config.mjs b/memory-v2-admin-config.mjs new file mode 100644 index 0000000..036d06a --- /dev/null +++ b/memory-v2-admin-config.mjs @@ -0,0 +1,363 @@ +import crypto from 'node:crypto'; +import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; + +const CONFIG_TABLE = 'h5_memory_v2_admin_config'; +const CONFIG_SCOPE = 'global'; + +const FIELD_SPECS = [ + { env: 'MEMORY_ENABLED', group: 'global', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_BACKEND', group: 'global', field: 'backend', type: 'string' }, + { env: 'MEMORY_PROFILE_ENABLED', group: 'global', field: 'profileEnabled', type: 'boolean' }, + { env: 'MEMORY_EVENT_LOG_ENABLED', group: 'global', field: 'eventLogEnabled', type: 'boolean' }, + { env: 'MEMORY_VECTOR_ENABLED', group: 'global', field: 'vectorEnabled', type: 'boolean' }, + { env: 'MEMORY_FAIL_OPEN', group: 'global', field: 'failOpen', type: 'boolean' }, + + { env: 'MEMORY_PGVECTOR_ENABLED', group: 'pgvector', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_PGVECTOR_DATABASE_URL', group: 'pgvector', field: 'databaseUrl', type: 'secret' }, + { env: 'MEMORY_PGVECTOR_TABLE', group: 'pgvector', field: 'table', type: 'string' }, + { env: 'MEMORY_PGVECTOR_EMBEDDING_MODULE', group: 'pgvector', field: 'embeddingModule', type: 'string' }, + { env: 'MEMORY_PGVECTOR_POOL_MAX', group: 'pgvector', field: 'poolMax', type: 'number' }, + { env: 'MEMORY_PGVECTOR_LIMIT', group: 'pgvector', field: 'limit', type: 'number' }, + + { env: 'MEMORY_QDRANT_ENABLED', group: 'qdrant', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_QDRANT_URL', group: 'qdrant', field: 'url', type: 'string' }, + { env: 'MEMORY_QDRANT_COLLECTION', group: 'qdrant', field: 'collection', type: 'string' }, + { env: 'MEMORY_QDRANT_EMBEDDING_MODULE', group: 'qdrant', field: 'embeddingModule', type: 'string' }, + { env: 'MEMORY_QDRANT_API_KEY', group: 'qdrant', field: 'apiKey', type: 'secret' }, + { env: 'MEMORY_QDRANT_TIMEOUT_MS', group: 'qdrant', field: 'timeoutMs', type: 'number' }, + { env: 'MEMORY_QDRANT_LIMIT', group: 'qdrant', field: 'limit', type: 'number' }, + + { env: 'MEMORY_WEAVIATE_ENABLED', group: 'weaviate', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_WEAVIATE_URL', group: 'weaviate', field: 'url', type: 'string' }, + { env: 'MEMORY_WEAVIATE_COLLECTION', group: 'weaviate', field: 'collection', type: 'string' }, + { env: 'MEMORY_WEAVIATE_EMBEDDING_MODULE', group: 'weaviate', field: 'embeddingModule', type: 'string' }, + { env: 'MEMORY_WEAVIATE_API_KEY', group: 'weaviate', field: 'apiKey', type: 'secret' }, + { env: 'MEMORY_WEAVIATE_TIMEOUT_MS', group: 'weaviate', field: 'timeoutMs', type: 'number' }, + { env: 'MEMORY_WEAVIATE_LIMIT', group: 'weaviate', field: 'limit', type: 'number' }, + + { env: 'MEMORY_MEM0_ENABLED', group: 'mem0', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_MEM0_API_KEY', group: 'mem0', field: 'apiKey', type: 'secret' }, + { env: 'MEMORY_MEM0_PROJECT_ID', group: 'mem0', field: 'projectId', type: 'string' }, + { env: 'MEMORY_MEM0_BASE_URL', group: 'mem0', field: 'baseUrl', type: 'string' }, + { env: 'MEMORY_MEM0_WRITE_PATH', group: 'mem0', field: 'writePath', type: 'string' }, + { env: 'MEMORY_MEM0_COMPACT_PATH', group: 'mem0', field: 'compactPath', type: 'string' }, + { env: 'MEMORY_MEM0_MODEL_PROVIDER_KEY_ID', group: 'mem0', field: 'modelProviderKeyId', type: 'string' }, + { env: 'MEMORY_MEM0_MODEL', group: 'mem0', field: 'model', type: 'string' }, + { env: 'MEMORY_MEM0_MODEL_API', group: 'mem0', field: 'modelApiType', type: 'string' }, + { env: 'MEMORY_MEM0_TIMEOUT_MS', group: 'mem0', field: 'timeoutMs', type: 'number' }, + + { env: 'MEMORY_LETTA_ENABLED', group: 'letta', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_LETTA_API_KEY', group: 'letta', field: 'apiKey', type: 'secret' }, + { env: 'MEMORY_LETTA_PROJECT_ID', group: 'letta', field: 'projectId', type: 'string' }, + { env: 'MEMORY_LETTA_AGENT_ID', group: 'letta', field: 'agentId', type: 'string' }, + { env: 'MEMORY_LETTA_BASE_URL', group: 'letta', field: 'baseUrl', type: 'string' }, + { env: 'MEMORY_LETTA_RESOLVE_PATH', group: 'letta', field: 'resolvePath', type: 'string' }, + { env: 'MEMORY_LETTA_WRITE_PATH', group: 'letta', field: 'writePath', type: 'string' }, + { env: 'MEMORY_LETTA_COMPACT_PATH', group: 'letta', field: 'compactPath', type: 'string' }, + { env: 'MEMORY_LETTA_MODEL_PROVIDER_KEY_ID', group: 'letta', field: 'modelProviderKeyId', type: 'string' }, + { env: 'MEMORY_LETTA_MODEL', group: 'letta', field: 'model', type: 'string' }, + { env: 'MEMORY_LETTA_MODEL_API', group: 'letta', field: 'modelApiType', type: 'string' }, + { env: 'MEMORY_LETTA_TIMEOUT_MS', group: 'letta', field: 'timeoutMs', type: 'number' }, + + { env: 'MEMORY_NEO4J_ENABLED', group: 'neo4j', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_NEO4J_URI', group: 'neo4j', field: 'uri', type: 'string' }, + { env: 'MEMORY_NEO4J_HTTP_URL', group: 'neo4j', field: 'httpUrl', type: 'string' }, + { env: 'MEMORY_NEO4J_USER', group: 'neo4j', field: 'user', type: 'string' }, + { env: 'MEMORY_NEO4J_PASSWORD', group: 'neo4j', field: 'password', type: 'secret' }, + { env: 'MEMORY_NEO4J_DATABASE', group: 'neo4j', field: 'database', type: 'string' }, + { env: 'MEMORY_NEO4J_TIMEOUT_MS', group: 'neo4j', field: 'timeoutMs', type: 'number' }, + + { env: 'MEMORY_REDIS_STREAMS_ENABLED', group: 'redisStreams', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_REDIS_STREAMS_URL', group: 'redisStreams', field: 'url', type: 'string' }, + { env: 'MEMORY_REDIS_STREAMS_STREAM', group: 'redisStreams', field: 'stream', type: 'string' }, + + { env: 'MEMORY_LANGGRAPH_ENABLED', group: 'langgraph', field: 'enabled', type: 'boolean' }, + { env: 'MEMORY_LANGGRAPH_URL', group: 'langgraph', field: 'url', type: 'string' }, + { env: 'MEMORY_LANGGRAPH_POLICY_ID', group: 'langgraph', field: 'policyId', type: 'string' }, + { env: 'MEMORY_LANGGRAPH_API_KEY', group: 'langgraph', field: 'apiKey', type: 'secret' }, + { env: 'MEMORY_LANGGRAPH_RESOLVE_PATH', group: 'langgraph', field: 'resolvePath', type: 'string' }, + { env: 'MEMORY_LANGGRAPH_MODEL_PROVIDER_KEY_ID', group: 'langgraph', field: 'modelProviderKeyId', type: 'string' }, + { env: 'MEMORY_LANGGRAPH_MODEL', group: 'langgraph', field: 'model', type: 'string' }, + { env: 'MEMORY_LANGGRAPH_MODEL_API', group: 'langgraph', field: 'modelApiType', type: 'string' }, + { env: 'MEMORY_LANGGRAPH_TIMEOUT_MS', group: 'langgraph', field: 'timeoutMs', type: 'number' }, +]; + +const GROUPS = [ + 'global', + 'pgvector', + 'qdrant', + 'weaviate', + 'mem0', + 'letta', + 'neo4j', + 'redisStreams', + 'langgraph', +]; + +function defaultConfigShape() { + return Object.fromEntries(GROUPS.map((group) => [group, {}])); +} + +function normalizeBoolean(value, fallback = false) { + if (value == null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function normalizeNumber(value) { + if (value == null || value === '') return ''; + const parsed = Number(value); + return Number.isFinite(parsed) ? String(parsed) : ''; +} + +function normalizeString(value) { + if (value == null) return ''; + return String(value).trim(); +} + +function normalizeSecretPatch(value) { + if (value === undefined) return undefined; + if (value === null) return ''; + return String(value); +} + +function cloneConfig(config = null) { + return structuredClone?.(config ?? defaultConfigShape()) + ?? JSON.parse(JSON.stringify(config ?? defaultConfigShape())); +} + +function parseJsonLike(value, fallback) { + if (value == null || value === '') return fallback; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return fallback; + } + } + if (typeof value === 'object') return value; + return fallback; +} + +function flattenConfig(config, secrets) { + const env = {}; + for (const spec of FIELD_SPECS) { + const value = spec.type === 'secret' + ? secrets?.[spec.group]?.[spec.field] + : config?.[spec.group]?.[spec.field]; + if (spec.type === 'boolean') { + env[spec.env] = normalizeBoolean(value, false) ? '1' : '0'; + } else if (spec.type === 'number') { + env[spec.env] = normalizeNumber(value); + } else { + env[spec.env] = normalizeString(value); + } + } + if (!env.MEMORY_PGVECTOR_ENABLED) { + env.MEMORY_PGVECTOR_ENABLED = env.MEMORY_VECTOR_ENABLED; + } + return env; +} + +function applyEnv(config, secrets, env = process.env) { + for (const spec of FIELD_SPECS) { + const raw = env?.[spec.env]; + if (spec.type === 'secret') { + if (raw != null && String(raw).trim() !== '') { + secrets[spec.group][spec.field] = String(raw).trim(); + } + continue; + } + if (spec.type === 'boolean') { + config[spec.group][spec.field] = normalizeBoolean(raw, Boolean(config[spec.group][spec.field])); + } else if (spec.type === 'number') { + const normalized = normalizeNumber(raw); + if (normalized !== '') config[spec.group][spec.field] = normalized; + } else if (raw != null && String(raw).trim() !== '') { + config[spec.group][spec.field] = String(raw).trim(); + } + } + if (config.pgvector.enabled === undefined) { + config.pgvector.enabled = normalizeBoolean(env?.MEMORY_VECTOR_ENABLED, false); + } + return { config, secrets }; +} + +function buildMaskedView(config, secrets) { + const view = cloneConfig(config); + for (const spec of FIELD_SPECS) { + if (spec.type !== 'secret') continue; + const raw = normalizeString(secrets?.[spec.group]?.[spec.field]); + view[spec.group][`${spec.field}Configured`] = raw.length > 0; + view[spec.group][`${spec.field}Masked`] = raw ? maskApiKey(raw) : ''; + } + return view; +} + +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( + config_scope VARCHAR(32) PRIMARY KEY, + config_json JSON NOT NULL, + secrets_ciphertext MEDIUMTEXT NULL, + secrets_iv VARCHAR(255) NULL, + secrets_tag VARCHAR(255) NULL, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +function hashState(config, secrets) { + return crypto + .createHash('sha256') + .update(JSON.stringify({ config, secrets })) + .digest('hex'); +} + +async function loadStoredState(pool) { + await ensureConfigTable(pool); + const [rows] = await pool.query( + `SELECT config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at + FROM ${CONFIG_TABLE} + WHERE config_scope = ? + LIMIT 1`, + [CONFIG_SCOPE], + ); + const row = rows[0]; + if (!row) return null; + let secrets = defaultConfigShape(); + if (row.secrets_ciphertext && row.secrets_iv && row.secrets_tag) { + secrets = { + ...defaultConfigShape(), + ...parseJsonLike( + decryptSecret( + { + ciphertext: row.secrets_ciphertext, + iv: row.secrets_iv, + tag: row.secrets_tag, + }, + ), + {}, + ), + }; + } + return { + config: { ...defaultConfigShape(), ...parseJsonLike(row.config_json, {}) }, + secrets, + updatedAt: Number(row.updated_at ?? 0) || null, + updatedBy: row.updated_by ?? null, + }; +} + +function mergePatch(currentConfig, currentSecrets, patch = {}) { + const nextConfig = cloneConfig(currentConfig); + const nextSecrets = cloneConfig(currentSecrets); + for (const spec of FIELD_SPECS) { + const section = patch?.[spec.group]; + if (!section || !(spec.field in section)) continue; + if (spec.type === 'secret') { + const value = normalizeSecretPatch(section[spec.field]); + if (value === undefined) continue; + nextSecrets[spec.group][spec.field] = normalizeString(value); + continue; + } + const value = section[spec.field]; + if (spec.type === 'boolean') { + nextConfig[spec.group][spec.field] = normalizeBoolean(value, false); + } else if (spec.type === 'number') { + nextConfig[spec.group][spec.field] = normalizeNumber(value); + } else { + nextConfig[spec.group][spec.field] = normalizeString(value); + } + } + return { nextConfig, nextSecrets }; +} + +export function createMemoryV2AdminConfigService(pool, { env = process.env } = {}) { + async function loadState() { + const defaults = applyEnv(defaultConfigShape(), defaultConfigShape(), env); + const stored = await loadStoredState(pool); + if (!stored) { + return { + config: defaults.config, + secrets: defaults.secrets, + updatedAt: null, + updatedBy: null, + }; + } + return { + config: { ...defaults.config, ...stored.config }, + secrets: { ...defaults.secrets, ...stored.secrets }, + updatedAt: stored.updatedAt, + updatedBy: stored.updatedBy, + }; + } + + return { + async getAdminConfig() { + const state = await loadState(); + return { + config: buildMaskedView(state.config, state.secrets), + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + }; + }, + + async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { + const state = await loadState(); + const { nextConfig, nextSecrets } = mergePatch(state.config, state.secrets, patch); + await ensureConfigTable(pool); + const now = Date.now(); + const encryptedSecrets = encryptSecret(JSON.stringify(nextSecrets)); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} + (config_scope, config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + secrets_ciphertext = VALUES(secrets_ciphertext), + secrets_iv = VALUES(secrets_iv), + secrets_tag = VALUES(secrets_tag), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [ + CONFIG_SCOPE, + JSON.stringify(nextConfig), + encryptedSecrets.ciphertext, + encryptedSecrets.iv, + encryptedSecrets.tag, + updatedBy, + now, + ], + ); + return this.getAdminConfig(); + }, + + async getRuntimeState() { + const state = await loadState(); + const overrides = flattenConfig(state.config, state.secrets); + return { + source: 'admin-db', + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + fingerprint: `${state.updatedAt ?? 'env'}:${hashState(state.config, state.secrets)}`, + overrides, + }; + }, + }; +} + +export const memoryV2AdminConfigInternals = { + CONFIG_SCOPE, + CONFIG_TABLE, + FIELD_SPECS, + defaultConfigShape, + flattenConfig, + applyEnv, + mergePatch, + buildMaskedView, +}; diff --git a/memory-v2-admin-config.test.mjs b/memory-v2-admin-config.test.mjs new file mode 100644 index 0000000..3922987 --- /dev/null +++ b/memory-v2-admin-config.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createMemoryV2AdminConfigService, + memoryV2AdminConfigInternals, +} from './memory-v2-admin-config.mjs'; + +function createPool(seedRow = null) { + const state = { row: seedRow }; + return { + state, + async query(sql, params) { + if (sql.includes('CREATE TABLE')) return [[], []]; + if (sql.includes('SELECT config_json')) { + return [state.row ? [state.row] : [], []]; + } + if (sql.includes('INSERT INTO h5_memory_v2_admin_config')) { + state.row = { + config_json: params[1], + secrets_ciphertext: params[2], + secrets_iv: params[3], + secrets_tag: params[4], + updated_by: params[5], + updated_at: params[6], + }; + return [[], []]; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; +} + +test('memory v2 admin config service loads defaults from env and masks secrets', async () => { + const service = createMemoryV2AdminConfigService(createPool(), { + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://user:pass@127.0.0.1:5432/memory', + MEMORY_QDRANT_API_KEY: 'secret-qdrant', + }, + }); + + const result = await service.getAdminConfig(); + + assert.equal(result.config.global.enabled, true); + assert.equal(result.config.global.backend, 'pgvector'); + assert.equal(result.config.pgvector.databaseUrlConfigured, true); + assert.match(result.config.pgvector.databaseUrlMasked, /^post\*+/); + assert.equal(result.config.qdrant.apiKeyConfigured, true); +}); + +test('memory v2 admin config service persists non-secret and secret patches', async () => { + const pool = createPool(); + const service = createMemoryV2AdminConfigService(pool, { + env: { + MEMORY_ENABLED: '0', + MEMORY_BACKEND: 'legacy', + }, + }); + + const updated = await service.updateAdminConfig({ + global: { enabled: true, backend: 'qdrant' }, + qdrant: { + enabled: true, + url: 'http://127.0.0.1:6333', + apiKey: 'secret-qdrant', + }, + }, { updatedBy: 'admin-1' }); + + assert.equal(updated.config.global.enabled, true); + assert.equal(updated.config.global.backend, 'qdrant'); + assert.equal(updated.config.qdrant.enabled, true); + assert.equal(updated.config.qdrant.url, 'http://127.0.0.1:6333'); + assert.equal(updated.config.qdrant.apiKeyConfigured, true); + assert.equal(updated.updatedBy, 'admin-1'); + + const runtimeState = await service.getRuntimeState(); + assert.equal(runtimeState.overrides.MEMORY_ENABLED, '1'); + assert.equal(runtimeState.overrides.MEMORY_BACKEND, 'qdrant'); + assert.equal(runtimeState.overrides.MEMORY_QDRANT_ENABLED, '1'); + assert.equal(runtimeState.overrides.MEMORY_QDRANT_URL, 'http://127.0.0.1:6333'); + assert.equal(runtimeState.overrides.MEMORY_QDRANT_API_KEY, 'secret-qdrant'); +}); + +test('memory v2 admin config internals flatten booleans and numbers consistently', () => { + const env = memoryV2AdminConfigInternals.flattenConfig( + { + ...memoryV2AdminConfigInternals.defaultConfigShape(), + global: { enabled: true, backend: 'pgvector', vectorEnabled: true }, + pgvector: { enabled: true, poolMax: '5', limit: '8' }, + }, + { + ...memoryV2AdminConfigInternals.defaultConfigShape(), + pgvector: { databaseUrl: 'postgresql://local/memory' }, + }, + ); + + assert.equal(env.MEMORY_ENABLED, '1'); + assert.equal(env.MEMORY_BACKEND, 'pgvector'); + assert.equal(env.MEMORY_VECTOR_ENABLED, '1'); + assert.equal(env.MEMORY_PGVECTOR_ENABLED, '1'); + assert.equal(env.MEMORY_PGVECTOR_POOL_MAX, '5'); + assert.equal(env.MEMORY_PGVECTOR_LIMIT, '8'); +}); diff --git a/memory-v2-backend-contract.mjs b/memory-v2-backend-contract.mjs new file mode 100644 index 0000000..778faae --- /dev/null +++ b/memory-v2-backend-contract.mjs @@ -0,0 +1,124 @@ +const ALLOWED_OPERATIONS = ['resolve', 'write', 'compact']; +const ALLOWED_PLUGIN_CATEGORIES = new Set([ + 'semantic', + 'extraction', + 'lifecycle', + 'behavior', + 'policy', +]); + +function check(name, ok, message = null, details = {}) { + return { name, ok: Boolean(ok), ...(message ? { message } : {}), ...details }; +} + +function isValidName(value) { + return /^[a-z][a-z0-9-]*$/.test(String(value ?? '')); +} + +function supports(backend, operation) { + return typeof backend?.[operation] === 'function'; +} + +function safeAvailable(backend) { + try { + return backend?.isAvailable?.() !== false; + } catch { + return false; + } +} + +export function inspectMemoryV2Backend(backend) { + const name = String(backend?.name ?? '').trim(); + const available = safeAvailable(backend); + const support = Object.fromEntries( + ALLOWED_OPERATIONS.map((operation) => [operation, supports(backend, operation)]), + ); + const checks = [ + check('name_present', Boolean(name), 'Backend name is required'), + check('name_format', isValidName(name), 'Backend name must be lowercase kebab-case', { + backendName: name, + }), + check( + 'has_operation', + Object.values(support).some(Boolean), + 'Backend must implement at least one Memory V2 operation', + { supports: support }, + ), + ]; + + if (!available) { + const reason = typeof backend?.getUnavailableReason === 'function' + ? backend.getUnavailableReason() + : backend?.unavailableReason; + checks.push(check( + 'unavailable_reason', + Boolean(reason), + 'Unavailable backend must expose a reason', + { reason: reason ?? null }, + )); + } + + if (backend?.category != null) { + checks.push(check( + 'category_known', + ALLOWED_PLUGIN_CATEGORIES.has(String(backend.category)), + 'Plugin backend category is unknown', + { category: String(backend.category) }, + )); + } + + if (backend?.flag != null) { + checks.push(check( + 'flag_format', + /^[A-Z0-9_]+$/.test(String(backend.flag)), + 'Backend feature flag must be SCREAMING_SNAKE_CASE', + { flag: String(backend.flag) }, + )); + } + + return { + ok: checks.every((item) => item.ok), + name, + available, + supports: support, + category: backend?.category ?? null, + role: backend?.role ?? null, + flag: backend?.flag ?? null, + checks, + }; +} + +export function validateMemoryV2BackendSet(backends = []) { + const inspected = backends.map((backend) => inspectMemoryV2Backend(backend)); + const names = inspected.map((item) => item.name).filter(Boolean); + const duplicateNames = names.filter((name, index) => names.indexOf(name) !== index); + const checks = [ + check('backend_list_present', Array.isArray(backends), 'Backends must be an array'), + check('backend_list_non_empty', inspected.length > 0, 'At least one backend is required'), + check('backend_names_unique', duplicateNames.length === 0, 'Backend names must be unique', { + duplicateNames: [...new Set(duplicateNames)], + }), + check( + 'legacy_backend_present', + names.includes('legacy-conversation-memory'), + 'Legacy conversation memory backend must remain registered', + ), + ]; + + return { + ok: checks.every((item) => item.ok) && inspected.every((item) => item.ok), + checkedAt: new Date().toISOString(), + summary: { + backendCount: inspected.length, + availableBackends: inspected.filter((item) => item.available).map((item) => item.name), + operationSupport: Object.fromEntries( + ALLOWED_OPERATIONS.map((operation) => [ + operation, + inspected.filter((item) => item.supports[operation]).map((item) => item.name), + ]), + ), + }, + checks, + backends: inspected, + }; +} diff --git a/memory-v2-backend-contract.test.mjs b/memory-v2-backend-contract.test.mjs new file mode 100644 index 0000000..343d483 --- /dev/null +++ b/memory-v2-backend-contract.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLegacyMemoryBackend } from './memory-v2.mjs'; +import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs'; +import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; +import { + inspectMemoryV2Backend, + validateMemoryV2BackendSet, +} from './memory-v2-backend-contract.mjs'; + +function legacyService() { + return { + async listMemories() { + return []; + }, + async saveAndAnalyze() { + return { saved: 0, analyzed: 0, memories: 0 }; + }, + async analyzeUser() { + return { analyzed: 0, memories: 0 }; + }, + }; +} + +test('inspectMemoryV2Backend accepts legacy backend contract', () => { + const report = inspectMemoryV2Backend(createLegacyMemoryBackend(legacyService())); + + assert.equal(report.ok, true); + assert.equal(report.name, 'legacy-conversation-memory'); + assert.equal(report.available, true); + assert.deepEqual(report.supports, { + resolve: true, + write: true, + compact: true, + }); +}); + +test('inspectMemoryV2Backend rejects malformed backend contracts', () => { + const report = inspectMemoryV2Backend({ + name: 'Bad Backend', + category: 'mystery', + flag: 'bad-flag', + isAvailable() { + return false; + }, + }); + + assert.equal(report.ok, false); + assert.equal(report.checks.find((check) => check.name === 'name_format').ok, false); + assert.equal(report.checks.find((check) => check.name === 'has_operation').ok, false); + assert.equal(report.checks.find((check) => check.name === 'unavailable_reason').ok, false); + assert.equal(report.checks.find((check) => check.name === 'category_known').ok, false); + assert.equal(report.checks.find((check) => check.name === 'flag_format').ok, false); +}); + +test('validateMemoryV2BackendSet verifies default backend registry', () => { + const report = validateMemoryV2BackendSet([ + createLegacyMemoryBackend(legacyService()), + ...createMemoryV2PluginBackends(), + ]); + + assert.equal(report.ok, true); + assert.equal(report.summary.backendCount, 9); + assert.deepEqual(report.summary.availableBackends, ['legacy-conversation-memory']); + assert.equal(report.summary.operationSupport.resolve.includes('qdrant'), true); + assert.equal(report.summary.operationSupport.write.includes('mem0'), true); + assert.equal(report.summary.operationSupport.compact.includes('letta'), true); +}); + +test('validateMemoryV2BackendSet rejects duplicate backend names and missing legacy', () => { + const report = validateMemoryV2BackendSet([ + { name: 'qdrant', unavailableReason: 'not_configured', isAvailable: () => false, resolve() {} }, + { name: 'qdrant', unavailableReason: 'not_configured', isAvailable: () => false, resolve() {} }, + ]); + + assert.equal(report.ok, false); + assert.equal(report.checks.find((check) => check.name === 'backend_names_unique').ok, false); + assert.equal(report.checks.find((check) => check.name === 'legacy_backend_present').ok, false); +}); + +test('validateMemoryV2BackendSet accepts real disabled pgvector adapter replacing placeholder', () => { + const report = validateMemoryV2BackendSet([ + createLegacyMemoryBackend(legacyService()), + createPgvectorMemoryBackend({ enabled: false }), + ...createMemoryV2PluginBackends({ exclude: ['pgvector'] }), + ]); + + assert.equal(report.ok, true); + assert.equal(report.backends.find((backend) => backend.name === 'pgvector').available, false); + assert.equal( + report.backends.find((backend) => backend.name === 'pgvector') + .checks.find((check) => check.name === 'unavailable_reason').ok, + true, + ); +}); diff --git a/memory-v2-external-adapters.test.mjs b/memory-v2-external-adapters.test.mjs new file mode 100644 index 0000000..b2d98c5 --- /dev/null +++ b/memory-v2-external-adapters.test.mjs @@ -0,0 +1,308 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { inspectMemoryV2Backend } from './memory-v2-backend-contract.mjs'; +import { createLangGraphHttpClient, createLangGraphMemoryBackend } from './memory-v2-langgraph.mjs'; +import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs'; +import { + createRedisStreamsClient, + createRedisStreamsMemoryBackend, +} from './memory-v2-redis-streams.mjs'; +import { createWeaviateHttpClient, createWeaviateMemoryBackend } from './memory-v2-weaviate.mjs'; + +test('weaviate backend is disabled by default and delegates only with client plus embedding', async () => { + const disabled = createWeaviateMemoryBackend(); + assert.equal(disabled.name, 'weaviate'); + assert.equal(disabled.category, 'semantic'); + assert.equal(disabled.role, 'knowledge-graph-fusion'); + assert.equal(disabled.flag, 'MEMORY_WEAVIATE_ENABLED'); + assert.equal(disabled.isAvailable(), false); + assert.equal(disabled.getUnavailableReason(), 'not_configured'); + assert.equal(inspectMemoryV2Backend(disabled).ok, true); + + const backend = createWeaviateMemoryBackend({ + enabled: true, + url: 'https://weaviate.local', + collection: 'MemindMemory', + async embedQuery(query) { + assert.equal(query, 'memory-chain'); + return [0.1, 0.2]; + }, + client: { + async search(input) { + assert.equal(input.collection, 'MemindMemory'); + assert.deepEqual(input.vector, [0.1, 0.2]); + return [{ id: 'w1', type: 'semantic', content: 'weaviate memory', score: 0.8 }]; + }, + }, + }); + const result = await backend.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.equal(backend.isAvailable(), true); + assert.deepEqual(result.semanticMemories, ['weaviate memory']); +}); + +test('weaviate backend validates url and collection', () => { + assert.throws( + () => createWeaviateMemoryBackend({ enabled: true, url: 'ftp://weaviate.local' }), + /Invalid MEMORY_WEAVIATE_URL/, + ); + assert.throws( + () => createWeaviateMemoryBackend({ collection: 'bad collection' }), + /Invalid MEMORY_WEAVIATE_COLLECTION/, + ); +}); + +test('weaviate HTTP client performs GraphQL vector search', async () => { + const client = createWeaviateHttpClient({ + url: 'https://weaviate.local', + apiKey: 'secret', + async fetchImpl(url, options) { + const body = JSON.parse(options.body); + assert.equal(url, 'https://weaviate.local/v1/graphql'); + assert.equal(options.headers.authorization, 'Bearer secret'); + assert.deepEqual(body.variables.where, { + path: ['user_id'], + operator: 'Equal', + valueText: 'u1', + }); + assert.match(body.query, /limit:\s*2/); + return { + ok: true, + async json() { + return { + data: { + Get: { + MemindMemory: [{ + content: 'weaviate memory', + type: 'semantic', + _additional: { id: 'w1', certainty: 0.91 }, + }], + }, + }, + }; + }, + }; + }, + }); + + const result = await client.search({ + collection: 'MemindMemory', + vector: [0.1, 0.2], + userId: 'u1', + limit: 2, + }); + + assert.deepEqual(result, [{ + id: 'w1', + label: 'semantic', + text: 'weaviate memory', + score: 0.91, + createdAt: undefined, + }]); +}); + +test('neo4j backend is disabled by default and delegates only with explicit client', async () => { + const disabled = createNeo4jMemoryBackend(); + assert.equal(disabled.name, 'neo4j'); + assert.equal(disabled.category, 'behavior'); + assert.equal(disabled.role, 'behavior-graph'); + assert.equal(disabled.flag, 'MEMORY_NEO4J_ENABLED'); + assert.equal(disabled.isAvailable(), false); + assert.equal(disabled.getUnavailableReason(), 'not_configured'); + assert.equal(inspectMemoryV2Backend(disabled).ok, true); + + const backend = createNeo4jMemoryBackend({ + enabled: true, + uri: 'neo4j://localhost:7687', + username: 'neo4j', + password: 'secret', + client: { + async resolve() { + return { behaviorSummary: 'prefers careful rollouts' }; + }, + async write() { + return { saved: 1, analyzed: 0, memories: 0 }; + }, + }, + }); + + assert.equal(backend.isAvailable(), true); + assert.equal((await backend.resolve({ userId: 'u1' })).behaviorSummary, 'prefers careful rollouts'); + assert.deepEqual(await backend.write({ userId: 'u1' }), { saved: 1, analyzed: 0, memories: 0 }); +}); + +test('neo4j HTTP client writes and resolves through transactional endpoint', async () => { + const calls = []; + const client = createNeo4jHttpClient({ + httpUrl: 'http://neo4j.local:7474', + username: 'neo4j', + password: 'secret', + async fetchImpl(url, options) { + calls.push({ url, body: JSON.parse(options.body), headers: options.headers }); + return { + ok: true, + async json() { + if (calls.length === 1) return { results: [{ data: [{ row: [1] }] }] }; + return { results: [{ data: [{ row: ['prefers careful rollouts'] }] }] }; + }, + }; + }, + }); + + assert.deepEqual(await client.write({ + userId: 'u1', + sessionId: 's1', + messages: [{ text: 'hello' }], + }), { saved: 1, analyzed: 0, memories: 0 }); + assert.equal((await client.resolve({ userId: 'u1' })).behaviorSummary, 'prefers careful rollouts'); + assert.equal(calls[0].url, 'http://neo4j.local:7474/db/neo4j/tx/commit'); + assert.match(calls[0].headers.authorization, /^Basic /); + assert.match(calls[0].body.statements[0].statement, /CREATE \(e:MemoryEvent/); +}); + +test('redis-streams backend is write-only and client-injected', async () => { + const disabled = createRedisStreamsMemoryBackend(); + assert.equal(disabled.name, 'redis-streams'); + assert.equal(disabled.category, 'behavior'); + assert.equal(disabled.role, 'event-tracking'); + assert.equal(disabled.flag, 'MEMORY_REDIS_STREAMS_ENABLED'); + assert.equal(disabled.isAvailable(), false); + assert.equal(disabled.getUnavailableReason(), 'not_configured'); + assert.equal(inspectMemoryV2Backend(disabled).ok, true); + assert.equal(typeof disabled.resolve, 'undefined'); + + const backend = createRedisStreamsMemoryBackend({ + enabled: true, + url: 'redis://localhost:6379', + stream: 'memind:memory-events', + client: { + async write(input) { + assert.equal(input.stream, 'memind:memory-events'); + return { saved: 1, analyzed: 0, memories: 0 }; + }, + }, + }); + + assert.equal(backend.isAvailable(), true); + assert.deepEqual(await backend.write({ userId: 'u1' }), { saved: 1, analyzed: 0, memories: 0 }); +}); + +test('redis streams client connects lazily, xadds event, and closes', async () => { + const calls = []; + const client = await createRedisStreamsClient({ + url: 'redis://localhost:6379', + async importRedis() { + return { + createClient(options) { + calls.push(['createClient', options.url]); + return { + async connect() { + calls.push(['connect']); + }, + async xAdd(stream, id, fields) { + calls.push(['xAdd', stream, id, fields.user_id]); + }, + async quit() { + calls.push(['quit']); + }, + }; + }, + }; + }, + }); + + assert.deepEqual(await client.write({ userId: 'u1', sessionId: 's1', messages: [] }), { + saved: 1, + analyzed: 0, + memories: 0, + }); + await client.close(); + assert.deepEqual(calls.map((call) => call.slice(0, 3)), [ + ['createClient', 'redis://localhost:6379'], + ['connect'], + ['xAdd', 'memind:memory-events', '*'], + ['quit'], + ]); +}); + +test('langgraph backend is disabled by default and delegates policy resolve only with client', async () => { + const disabled = createLangGraphMemoryBackend(); + assert.equal(disabled.name, 'langgraph'); + assert.equal(disabled.category, 'policy'); + assert.equal(disabled.role, 'routing-reasoning-policy'); + assert.equal(disabled.flag, 'MEMORY_LANGGRAPH_ENABLED'); + assert.equal(disabled.isAvailable(), false); + assert.equal(disabled.getUnavailableReason(), 'not_configured'); + assert.equal(inspectMemoryV2Backend(disabled).ok, true); + + const backend = createLangGraphMemoryBackend({ + enabled: true, + policyId: 'memory_policy', + client: { + async resolve(input) { + assert.equal(input.policyId, 'memory_policy'); + return { activeGoals: ['route-memory'] }; + }, + }, + }); + + assert.equal(backend.isAvailable(), true); + assert.deepEqual((await backend.resolve({ userId: 'u1' })).activeGoals, ['route-memory']); +}); + +test('langgraph HTTP client posts resolve payload', async () => { + const client = createLangGraphHttpClient({ + baseUrl: 'https://langgraph.local', + apiKey: 'secret', + modelProviderKeyId: 'key_langgraph', + model: 'gpt-4.1-nano', + modelApiType: 'response', + async fetchImpl(url, options) { + assert.equal(url, 'https://langgraph.local/memory/resolve'); + assert.equal(options.headers.authorization, 'Bearer secret'); + assert.deepEqual(JSON.parse(options.body), { + user_id: 'u1', + session_id: 's1', + query: 'hello', + policy_id: 'memory_policy', + limit: 3, + model_provider_key_id: 'key_langgraph', + model: 'gpt-4.1-nano', + model_api_type: 'response', + }); + return { + ok: true, + async json() { + return { activeGoals: ['route-memory'] }; + }, + }; + }, + }); + + assert.deepEqual(await client.resolve({ + userId: 'u1', + sessionId: 's1', + query: 'hello', + policyId: 'memory_policy', + limit: 3, + }), { activeGoals: ['route-memory'] }); +}); + +test('external adapter skeletons validate unsafe config values', () => { + assert.throws( + () => createNeo4jMemoryBackend({ uri: 'http://localhost:7474' }), + /Invalid MEMORY_NEO4J_URI/, + ); + assert.throws( + () => createRedisStreamsMemoryBackend({ url: 'http://localhost:6379' }), + /Invalid MEMORY_REDIS_STREAMS_URL/, + ); + assert.throws( + () => createRedisStreamsMemoryBackend({ stream: 'bad stream' }), + /Invalid MEMORY_REDIS_STREAMS_STREAM/, + ); + assert.throws( + () => createLangGraphMemoryBackend({ policyId: 'bad policy id' }), + /Invalid MEMORY_LANGGRAPH_POLICY_ID/, + ); +}); diff --git a/memory-v2-health.mjs b/memory-v2-health.mjs new file mode 100644 index 0000000..b5933a0 --- /dev/null +++ b/memory-v2-health.mjs @@ -0,0 +1,152 @@ +const REQUIRED_STATUS_KEYS = [ + 'enabled', + 'backend', + 'selectedBackend', + 'profileEnabled', + 'eventLogEnabled', + 'vectorEnabled', + 'failOpen', + 'backends', +]; + +function pass(name, details = {}) { + return { name, ok: true, ...details }; +} + +function fail(name, message, details = {}) { + return { name, ok: false, message, ...details }; +} + +function findBackend(status, name) { + return Array.isArray(status?.backends) + ? status.backends.find((backend) => backend?.name === name) + : null; +} + +function missingStatusKeys(status) { + return REQUIRED_STATUS_KEYS.filter((key) => !(key in Object(status))); +} + +export function evaluateMemoryV2Health({ + status, + writeResult = null, + compactResult = null, + expectedBackend = null, + requireEnabled = false, + requireFailOpen = true, +} = {}) { + const checks = []; + const missing = missingStatusKeys(status); + + checks.push( + missing.length === 0 + ? pass('status_contract') + : fail('status_contract', `Missing status keys: ${missing.join(', ')}`, { missing }), + ); + + if (requireEnabled) { + checks.push( + status?.enabled === true + ? pass('memory_enabled') + : fail('memory_enabled', 'MEMORY_ENABLED is not enabled', { enabled: status?.enabled }), + ); + } else { + checks.push(pass('memory_enabled_optional', { enabled: Boolean(status?.enabled) })); + } + + if (requireFailOpen) { + checks.push( + status?.failOpen === true + ? pass('fail_open') + : fail('fail_open', 'MEMORY_FAIL_OPEN must remain enabled for release safety', { + failOpen: status?.failOpen, + }), + ); + } + + if (expectedBackend) { + checks.push( + status?.backend === expectedBackend + ? pass('expected_backend', { backend: status?.backend }) + : fail('expected_backend', `Expected MEMORY_BACKEND=${expectedBackend}`, { + backend: status?.backend, + }), + ); + } + + const legacy = findBackend(status, 'legacy-conversation-memory'); + checks.push( + legacy?.available === true + ? pass('legacy_available') + : fail('legacy_available', 'Legacy conversation-memory backend must be available', { + legacy, + }), + ); + + checks.push( + legacy?.supports?.write === true && legacy?.supports?.compact === true + ? pass('legacy_write_compact_supported') + : fail('legacy_write_compact_supported', 'Legacy backend must support write and compact', { + supports: legacy?.supports ?? null, + }), + ); + + const selectedBackend = status?.selectedBackend ?? null; + checks.push( + selectedBackend + ? pass('selected_backend_present', { selectedBackend }) + : fail('selected_backend_present', 'No selected Memory V2 backend for resolve'), + ); + + const selected = findBackend(status, selectedBackend); + checks.push( + !selected || selected.available === true + ? pass('selected_backend_available', { selectedBackend }) + : fail('selected_backend_available', 'Selected backend is not available', { selected }), + ); + + const pgvector = findBackend(status, 'pgvector'); + if (status?.backend === 'pgvector') { + checks.push( + pgvector?.available === true + ? pass('pgvector_canary_available') + : pass('pgvector_canary_fallback', { + reason: pgvector?.reason ?? 'unavailable', + selectedBackend, + }), + ); + } + + if (writeResult) { + checks.push( + writeResult.source === 'legacy-conversation-memory' + ? pass('write_uses_legacy', { source: writeResult.source }) + : fail('write_uses_legacy', 'Memory V2 write must use legacy backend during semantic canary', { + source: writeResult.source ?? null, + }), + ); + } + + if (compactResult) { + checks.push( + compactResult.source === 'legacy-conversation-memory' + ? pass('compact_uses_legacy', { source: compactResult.source }) + : fail('compact_uses_legacy', 'Memory V2 compact must use legacy backend during semantic canary', { + source: compactResult.source ?? null, + }), + ); + } + + return { + ok: checks.every((check) => check.ok), + checkedAt: new Date().toISOString(), + summary: { + enabled: Boolean(status?.enabled), + backend: status?.backend ?? null, + selectedBackend, + failOpen: Boolean(status?.failOpen), + backendCount: Array.isArray(status?.backends) ? status.backends.length : 0, + }, + checks, + }; +} diff --git a/memory-v2-health.test.mjs b/memory-v2-health.test.mjs new file mode 100644 index 0000000..1efe84b --- /dev/null +++ b/memory-v2-health.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { evaluateMemoryV2Health } from './memory-v2-health.mjs'; + +function healthyStatus(overrides = {}) { + return { + enabled: true, + backend: 'legacy', + selectedBackend: 'legacy-conversation-memory', + profileEnabled: true, + eventLogEnabled: true, + vectorEnabled: false, + failOpen: true, + backends: [ + { + name: 'legacy-conversation-memory', + available: true, + supports: { + resolve: true, + write: true, + compact: true, + }, + }, + { + name: 'pgvector', + available: false, + supports: { + resolve: true, + write: false, + compact: false, + }, + reason: 'not_configured', + }, + ], + ...overrides, + }; +} + +test('evaluateMemoryV2Health passes for legacy-safe status', () => { + const report = evaluateMemoryV2Health({ + status: healthyStatus(), + writeResult: { source: 'legacy-conversation-memory' }, + compactResult: { source: 'legacy-conversation-memory' }, + requireEnabled: true, + expectedBackend: 'legacy', + }); + + assert.equal(report.ok, true); + assert.equal(report.summary.selectedBackend, 'legacy-conversation-memory'); + assert.equal(report.checks.every((check) => check.ok), true); +}); + +test('evaluateMemoryV2Health fails when status contract and fail-open are broken', () => { + const report = evaluateMemoryV2Health({ + status: { + enabled: true, + backend: 'legacy', + selectedBackend: null, + failOpen: false, + backends: [], + }, + requireEnabled: true, + }); + + assert.equal(report.ok, false); + assert.equal(report.checks.find((check) => check.name === 'status_contract').ok, false); + assert.equal(report.checks.find((check) => check.name === 'fail_open').ok, false); + assert.equal(report.checks.find((check) => check.name === 'legacy_available').ok, false); +}); + +test('evaluateMemoryV2Health allows pgvector resolve canary while requiring legacy write and compact', () => { + const report = evaluateMemoryV2Health({ + status: healthyStatus({ + backend: 'pgvector', + selectedBackend: 'pgvector', + vectorEnabled: true, + backends: [ + healthyStatus().backends[0], + { + name: 'pgvector', + available: true, + supports: { + resolve: true, + write: false, + compact: false, + }, + }, + ], + }), + writeResult: { source: 'legacy-conversation-memory' }, + compactResult: { source: 'legacy-conversation-memory' }, + expectedBackend: 'pgvector', + }); + + assert.equal(report.ok, true); + assert.equal(report.checks.find((check) => check.name === 'pgvector_canary_available').ok, true); +}); + +test('evaluateMemoryV2Health fails when semantic canary captures writes', () => { + const report = evaluateMemoryV2Health({ + status: healthyStatus({ + backend: 'pgvector', + selectedBackend: 'pgvector', + vectorEnabled: true, + }), + writeResult: { source: 'pgvector' }, + compactResult: { source: 'legacy-conversation-memory' }, + }); + + assert.equal(report.ok, false); + assert.equal(report.checks.find((check) => check.name === 'write_uses_legacy').ok, false); +}); diff --git a/memory-v2-langgraph.mjs b/memory-v2-langgraph.mjs new file mode 100644 index 0000000..4ba5637 --- /dev/null +++ b/memory-v2-langgraph.mjs @@ -0,0 +1,114 @@ +function normalizePolicyId(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + if (!/^[a-zA-Z0-9_-]+$/.test(raw)) { + throw new Error(`Invalid MEMORY_LANGGRAPH_POLICY_ID: ${raw}`); + } + return raw; +} + +function timeoutSignal(timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + clear() { + clearTimeout(timeout); + }, + }; +} + +function joinUrl(baseUrl, path) { + return `${String(baseUrl).replace(/\/$/, '')}/${String(path).replace(/^\//, '')}`; +} + +export function createLangGraphHttpClient({ + baseUrl, + apiKey = null, + resolvePath = '/memory/resolve', + modelProviderKeyId = null, + model = null, + modelApiType = null, + fetchImpl = globalThis.fetch, + timeoutMs = 3000, +} = {}) { + const resolvedBaseUrl = String(baseUrl ?? '').trim(); + if (!resolvedBaseUrl) throw new Error('createLangGraphHttpClient requires MEMORY_LANGGRAPH_URL'); + if (typeof fetchImpl !== 'function') throw new Error('createLangGraphHttpClient requires fetch'); + + return { + async resolve(input = {}) { + const timeout = timeoutSignal(Number(timeoutMs)); + try { + const response = await fetchImpl(joinUrl(resolvedBaseUrl, resolvePath), { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + user_id: input.userId, + session_id: input.sessionId, + query: input.query, + policy_id: input.policyId, + limit: input.limit, + model_provider_key_id: String(modelProviderKeyId ?? '').trim() || null, + model: String(model ?? '').trim() || null, + model_api_type: String(modelApiType ?? '').trim() || null, + }), + ...(timeout?.signal ? { signal: timeout.signal } : {}), + }); + if (!response?.ok) { + throw new Error(`LangGraph request failed: ${response?.status ?? 'unknown'}`); + } + return response.json?.() ?? {}; + } finally { + timeout?.clear(); + } + }, + }; +} + +export function createLangGraphMemoryBackend({ + enabled = false, + policyId = null, + client = null, + unavailableReason = null, +} = {}) { + const resolvedPolicyId = normalizePolicyId(policyId); + const hasClient = Boolean(client?.resolve); + const wired = Boolean(enabled && hasClient); + const reason = unavailableReason + ?? (enabled ? 'client_not_configured' : 'not_configured'); + + return { + name: 'langgraph', + category: 'policy', + role: 'routing-reasoning-policy', + flag: 'MEMORY_LANGGRAPH_ENABLED', + unavailableReason: reason, + policyId: resolvedPolicyId, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async resolve(input = {}) { + if (!this.isAvailable()) { + return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [] }; + } + const payload = await client.resolve({ ...input, policyId: resolvedPolicyId }); + return { + memories: Array.isArray(payload?.memories) ? payload.memories : [], + semanticMemories: Array.isArray(payload?.semanticMemories) ? payload.semanticMemories : [], + behaviorSummary: payload?.behaviorSummary ?? null, + activeGoals: Array.isArray(payload?.activeGoals) ? payload.activeGoals : [], + }; + }, + }; +} diff --git a/memory-v2-letta.mjs b/memory-v2-letta.mjs new file mode 100644 index 0000000..4bc8d7e --- /dev/null +++ b/memory-v2-letta.mjs @@ -0,0 +1,226 @@ +function normalizeLettaId(value, envName) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + if (!/^[a-zA-Z0-9_-]+$/.test(raw)) { + throw new Error(`Invalid ${envName}: ${raw}`); + } + return raw; +} + +function normalizeMemory(item) { + if (typeof item === 'string') { + const text = item.trim(); + return text ? { label: 'lifecycle', text } : null; + } + const text = String( + item?.text ?? item?.content ?? item?.memory_text ?? item?.memoryText ?? '', + ).trim(); + if (!text) return null; + return { + id: item?.id == null ? null : String(item.id), + label: item?.label ?? item?.type ?? 'lifecycle', + text, + createdAt: item?.createdAt ?? item?.created_at ?? null, + }; +} + +function normalizeResolvePayload(payload = {}) { + const memories = Array.isArray(payload?.memories) + ? payload.memories.map((item) => normalizeMemory(item)).filter(Boolean) + : []; + return { + memories, + semanticMemories: Array.isArray(payload?.semanticMemories) + ? payload.semanticMemories + : memories.map((memory) => memory.text), + behaviorSummary: payload?.behaviorSummary ?? null, + activeGoals: Array.isArray(payload?.activeGoals) ? payload.activeGoals : [], + profile: payload?.profile ?? null, + }; +} + +function timeoutSignal(timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + clear() { + clearTimeout(timeout); + }, + }; +} + +function joinUrl(baseUrl, path, params = {}) { + let resolvedPath = String(path); + for (const [key, value] of Object.entries(params)) { + resolvedPath = resolvedPath.replaceAll(`{${key}}`, encodeURIComponent(String(value ?? ''))); + } + return `${String(baseUrl).replace(/\/$/, '')}/${resolvedPath.replace(/^\//, '')}`; +} + +export function createLettaHttpClient({ + apiKey, + baseUrl = 'https://api.letta.com', + agentId = null, + projectId = null, + resolvePath = '/v1/agents/{agentId}/memory', + writePath = '/v1/agents/{agentId}/messages', + compactPath = '/v1/agents/{agentId}/memory/compact', + modelProviderKeyId = null, + model = null, + modelApiType = null, + fetchImpl = globalThis.fetch, + timeoutMs = 3000, +} = {}) { + const resolvedApiKey = String(apiKey ?? '').trim(); + if (!resolvedApiKey) throw new Error('createLettaHttpClient requires MEMORY_LETTA_API_KEY'); + if (typeof fetchImpl !== 'function') throw new Error('createLettaHttpClient requires fetch'); + const resolvedAgentId = normalizeLettaId(agentId, 'MEMORY_LETTA_AGENT_ID'); + const resolvedProjectId = normalizeLettaId(projectId, 'MEMORY_LETTA_PROJECT_ID'); + + async function requestJson(method, path, body = null) { + const timeout = timeoutSignal(Number(timeoutMs)); + try { + const response = await fetchImpl(joinUrl(baseUrl, path, { agentId: resolvedAgentId }), { + method, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${resolvedApiKey}`, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + ...(timeout?.signal ? { signal: timeout.signal } : {}), + }); + if (!response?.ok) { + throw new Error(`Letta request failed: ${response?.status ?? 'unknown'}`); + } + return response.json?.() ?? {}; + } finally { + timeout?.clear(); + } + } + + return { + async resolve(input = {}) { + return requestJson('POST', resolvePath, { + user_id: input.userId, + query: input.query, + limit: input.limit, + project_id: resolvedProjectId, + model_provider_key_id: String(modelProviderKeyId ?? '').trim() || null, + model: String(model ?? '').trim() || null, + model_api_type: String(modelApiType ?? '').trim() || null, + }); + }, + async write(input = {}) { + const data = await requestJson('POST', writePath, { + user_id: input.userId, + session_id: input.sessionId, + messages: Array.isArray(input.messages) ? input.messages : [], + project_id: resolvedProjectId, + model_provider_key_id: String(modelProviderKeyId ?? '').trim() || null, + model: String(model ?? '').trim() || null, + model_api_type: String(modelApiType ?? '').trim() || null, + }); + return { + saved: Number(data?.saved ?? data?.count ?? 1), + analyzed: Number(data?.analyzed ?? 0), + memories: Number(data?.memories ?? data?.memory_count ?? 0), + }; + }, + async compact(input = {}) { + const data = await requestJson('POST', compactPath, { + user_id: input.userId, + project_id: resolvedProjectId, + model_provider_key_id: String(modelProviderKeyId ?? '').trim() || null, + model: String(model ?? '').trim() || null, + model_api_type: String(modelApiType ?? '').trim() || null, + }); + return { + analyzed: Number(data?.analyzed ?? 1), + memories: Number(data?.memories ?? data?.memory_count ?? 0), + }; + }, + }; +} + +export function createLettaMemoryBackend({ + enabled = false, + apiKey = null, + projectId = null, + agentId = null, + client = null, + unavailableReason = null, +} = {}) { + const hasApiKey = Boolean(String(apiKey ?? '').trim()); + const resolvedProjectId = normalizeLettaId(projectId, 'MEMORY_LETTA_PROJECT_ID'); + const resolvedAgentId = normalizeLettaId(agentId, 'MEMORY_LETTA_AGENT_ID'); + const configured = Boolean(enabled && hasApiKey); + const hasClient = Boolean(client?.resolve && client?.write && client?.compact); + const wired = Boolean(configured && hasClient); + const reason = unavailableReason + ?? (enabled + ? (!hasApiKey ? 'api_key_not_configured' : 'client_not_configured') + : 'not_configured'); + + return { + name: 'letta', + category: 'lifecycle', + role: 'long-short-term-memory-os', + flag: 'MEMORY_LETTA_ENABLED', + unavailableReason: reason, + projectId: resolvedProjectId, + agentId: resolvedAgentId, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async resolve(input = {}) { + if (!this.isAvailable()) { + return { + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }; + } + return normalizeResolvePayload(await client.resolve({ + ...input, + projectId: resolvedProjectId, + agentId: resolvedAgentId, + })); + }, + + async write(input = {}) { + if (!this.isAvailable()) return { saved: 0, analyzed: 0, memories: 0 }; + const result = await client.write({ + ...input, + projectId: resolvedProjectId, + agentId: resolvedAgentId, + }); + return { + saved: Number(result?.saved ?? 0), + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + }, + + async compact(input = {}) { + if (!this.isAvailable()) return { analyzed: 0, memories: 0 }; + const result = await client.compact({ + ...input, + projectId: resolvedProjectId, + agentId: resolvedAgentId, + }); + return { + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + }, + }; +} diff --git a/memory-v2-letta.test.mjs b/memory-v2-letta.test.mjs new file mode 100644 index 0000000..c638679 --- /dev/null +++ b/memory-v2-letta.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { inspectMemoryV2Backend } from './memory-v2-backend-contract.mjs'; +import { createLettaHttpClient, createLettaMemoryBackend } from './memory-v2-letta.mjs'; + +test('letta backend is disabled by default and passes contract gate', async () => { + const backend = createLettaMemoryBackend(); + const report = inspectMemoryV2Backend(backend); + + assert.equal(backend.name, 'letta'); + assert.equal(backend.category, 'lifecycle'); + assert.equal(backend.role, 'long-short-term-memory-os'); + assert.equal(backend.flag, 'MEMORY_LETTA_ENABLED'); + assert.equal(backend.isAvailable(), false); + assert.equal(backend.getUnavailableReason(), 'not_configured'); + assert.equal(report.ok, true); + assert.deepEqual(await backend.resolve({}), { + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }); + assert.deepEqual(await backend.write({}), { saved: 0, analyzed: 0, memories: 0 }); + assert.deepEqual(await backend.compact({}), { analyzed: 0, memories: 0 }); +}); + +test('letta backend reports api key and client configuration reasons', () => { + assert.equal( + createLettaMemoryBackend({ enabled: true }).getUnavailableReason(), + 'api_key_not_configured', + ); + assert.equal( + createLettaMemoryBackend({ + enabled: true, + apiKey: 'secret', + projectId: 'memind_project', + agentId: 'agent_1', + }).getUnavailableReason(), + 'client_not_configured', + ); +}); + +test('letta backend validates project and agent ids', () => { + assert.throws( + () => createLettaMemoryBackend({ projectId: 'bad project id' }), + /Invalid MEMORY_LETTA_PROJECT_ID/, + ); + assert.throws( + () => createLettaMemoryBackend({ agentId: 'bad agent id' }), + /Invalid MEMORY_LETTA_AGENT_ID/, + ); +}); + +test('letta backend delegates only when explicitly wired with a full client', async () => { + const calls = []; + const backend = createLettaMemoryBackend({ + enabled: true, + apiKey: 'secret', + projectId: 'memind_project', + agentId: 'agent_1', + client: { + async resolve(input) { + calls.push(['resolve', input]); + return { + memories: [{ id: 1, type: 'goal', content: 'keep long-term context tidy' }], + activeGoals: ['memory-v2'], + }; + }, + async write(input) { + calls.push(['write', input]); + return { saved: 2, analyzed: 1, memories: 1 }; + }, + async compact(input) { + calls.push(['compact', input]); + return { analyzed: 3, memories: 2 }; + }, + }, + }); + + assert.equal(backend.isAvailable(), true); + assert.equal(backend.getUnavailableReason(), null); + assert.deepEqual(await backend.resolve({ userId: 'u1' }), { + memories: [{ + id: '1', + label: 'goal', + text: 'keep long-term context tidy', + createdAt: null, + }], + semanticMemories: ['keep long-term context tidy'], + behaviorSummary: null, + activeGoals: ['memory-v2'], + profile: null, + }); + assert.deepEqual(await backend.write({ userId: 'u1' }), { + saved: 2, + analyzed: 1, + memories: 1, + }); + assert.deepEqual(await backend.compact({ userId: 'u1' }), { + analyzed: 3, + memories: 2, + }); + assert.deepEqual(calls.map(([name, input]) => [name, input.projectId, input.agentId]), [ + ['resolve', 'memind_project', 'agent_1'], + ['write', 'memind_project', 'agent_1'], + ['compact', 'memind_project', 'agent_1'], + ]); +}); + +test('letta HTTP client resolves, writes, and compacts with agent-scoped paths', async () => { + const calls = []; + const client = createLettaHttpClient({ + apiKey: 'secret', + baseUrl: 'https://letta.local', + agentId: 'agent_1', + projectId: 'memind_project', + modelProviderKeyId: 'key_letta', + model: 'gpt-4.1', + modelApiType: 'chat', + async fetchImpl(url, options) { + calls.push({ url, headers: options.headers, body: options.body ? JSON.parse(options.body) : null }); + return { + ok: true, + async json() { + if (url.endsWith('/memory')) return { memories: ['letta memory'] }; + return { saved: 1, analyzed: 1, memories: 1 }; + }, + }; + }, + }); + + assert.deepEqual(await client.resolve({ userId: 'u1', query: 'hello' }), { + memories: ['letta memory'], + }); + assert.deepEqual(await client.write({ userId: 'u1', sessionId: 's1', messages: [] }), { + saved: 1, + analyzed: 1, + memories: 1, + }); + assert.deepEqual(await client.compact({ userId: 'u1' }), { + analyzed: 1, + memories: 1, + }); + assert.deepEqual(calls.map((call) => call.url), [ + 'https://letta.local/v1/agents/agent_1/memory', + 'https://letta.local/v1/agents/agent_1/messages', + 'https://letta.local/v1/agents/agent_1/memory/compact', + ]); + assert.equal(calls[0].headers.authorization, 'Bearer secret'); + assert.equal(calls[0].body.project_id, 'memind_project'); + assert.equal(calls[0].body.model_provider_key_id, 'key_letta'); + assert.equal(calls[0].body.model, 'gpt-4.1'); + assert.equal(calls[0].body.model_api_type, 'chat'); + assert.equal(calls[1].body.model_provider_key_id, 'key_letta'); + assert.equal(calls[2].body.model_api_type, 'chat'); +}); diff --git a/memory-v2-mem0.mjs b/memory-v2-mem0.mjs new file mode 100644 index 0000000..a124e36 --- /dev/null +++ b/memory-v2-mem0.mjs @@ -0,0 +1,149 @@ +function normalizeProjectId(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + if (!/^[a-zA-Z0-9_-]+$/.test(raw)) { + throw new Error(`Invalid MEMORY_MEM0_PROJECT_ID: ${raw}`); + } + return raw; +} + +function timeoutSignal(timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + clear() { + clearTimeout(timeout); + }, + }; +} + +function joinUrl(baseUrl, path) { + return `${String(baseUrl).replace(/\/$/, '')}/${String(path).replace(/^\//, '')}`; +} + +export function createMem0HttpClient({ + apiKey, + projectId = null, + baseUrl = 'https://api.mem0.ai', + writePath = '/v1/memories', + compactPath = '/v1/memories/compact', + modelProviderKeyId = null, + model = null, + modelApiType = null, + fetchImpl = globalThis.fetch, + timeoutMs = 3000, +} = {}) { + const resolvedApiKey = String(apiKey ?? '').trim(); + if (!resolvedApiKey) throw new Error('createMem0HttpClient requires MEMORY_MEM0_API_KEY'); + if (typeof fetchImpl !== 'function') throw new Error('createMem0HttpClient requires fetch'); + const resolvedProjectId = normalizeProjectId(projectId); + + async function postJson(path, body) { + const timeout = timeoutSignal(Number(timeoutMs)); + try { + const response = await fetchImpl(joinUrl(baseUrl, path), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${resolvedApiKey}`, + }, + body: JSON.stringify(body), + ...(timeout?.signal ? { signal: timeout.signal } : {}), + }); + if (!response?.ok) { + throw new Error(`Mem0 request failed: ${response?.status ?? 'unknown'}`); + } + return response.json?.() ?? {}; + } finally { + timeout?.clear(); + } + } + + return { + async write(input = {}) { + const data = await postJson(writePath, { + user_id: input.userId, + session_id: input.sessionId, + messages: Array.isArray(input.messages) ? input.messages : [], + project_id: resolvedProjectId, + model_provider_key_id: String(modelProviderKeyId ?? '').trim() || null, + model: String(model ?? '').trim() || null, + model_api_type: String(modelApiType ?? '').trim() || null, + }); + return { + saved: Number(data?.saved ?? data?.count ?? 1), + analyzed: Number(data?.analyzed ?? 1), + memories: Number(data?.memories ?? data?.memory_count ?? data?.count ?? 0), + }; + }, + async compact(input = {}) { + const data = await postJson(compactPath, { + user_id: input.userId, + project_id: resolvedProjectId, + model_provider_key_id: String(modelProviderKeyId ?? '').trim() || null, + model: String(model ?? '').trim() || null, + model_api_type: String(modelApiType ?? '').trim() || null, + }); + return { + analyzed: Number(data?.analyzed ?? 1), + memories: Number(data?.memories ?? data?.memory_count ?? data?.count ?? 0), + }; + }, + }; +} + +export function createMem0MemoryBackend({ + enabled = false, + apiKey = null, + projectId = null, + client = null, + unavailableReason = null, +} = {}) { + const hasApiKey = Boolean(String(apiKey ?? '').trim()); + const resolvedProjectId = normalizeProjectId(projectId); + const configured = Boolean(enabled && hasApiKey); + const hasClient = Boolean(client?.write || client?.compact); + const wired = Boolean(configured && hasClient); + const reason = unavailableReason + ?? (enabled + ? (!hasApiKey ? 'api_key_not_configured' : 'client_not_configured') + : 'not_configured'); + + return { + name: 'mem0', + category: 'extraction', + role: 'automatic-memory-generation', + flag: 'MEMORY_MEM0_ENABLED', + unavailableReason: reason, + projectId: resolvedProjectId, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async write(input = {}) { + if (!this.isAvailable() || !client?.write) return { saved: 0, analyzed: 0, memories: 0 }; + const result = await client.write({ ...input, projectId: resolvedProjectId }); + return { + saved: Number(result?.saved ?? 0), + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + }, + + async compact(input = {}) { + if (!this.isAvailable() || !client?.compact) return { analyzed: 0, memories: 0 }; + const result = await client.compact({ ...input, projectId: resolvedProjectId }); + return { + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + }, + }; +} diff --git a/memory-v2-mem0.test.mjs b/memory-v2-mem0.test.mjs new file mode 100644 index 0000000..fcde7f6 --- /dev/null +++ b/memory-v2-mem0.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { inspectMemoryV2Backend } from './memory-v2-backend-contract.mjs'; +import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs'; + +test('mem0 backend is disabled by default and passes contract gate', async () => { + const backend = createMem0MemoryBackend(); + const report = inspectMemoryV2Backend(backend); + + assert.equal(backend.name, 'mem0'); + assert.equal(backend.category, 'extraction'); + assert.equal(backend.role, 'automatic-memory-generation'); + assert.equal(backend.flag, 'MEMORY_MEM0_ENABLED'); + assert.equal(backend.isAvailable(), false); + assert.equal(backend.getUnavailableReason(), 'not_configured'); + assert.equal(report.ok, true); + assert.deepEqual(await backend.write({}), { saved: 0, analyzed: 0, memories: 0 }); + assert.deepEqual(await backend.compact({}), { analyzed: 0, memories: 0 }); +}); + +test('mem0 backend reports api key and client configuration reasons', () => { + assert.equal( + createMem0MemoryBackend({ enabled: true }).getUnavailableReason(), + 'api_key_not_configured', + ); + assert.equal( + createMem0MemoryBackend({ + enabled: true, + apiKey: 'secret', + projectId: 'memind_project', + }).getUnavailableReason(), + 'client_not_configured', + ); +}); + +test('mem0 backend validates project id', () => { + assert.throws( + () => createMem0MemoryBackend({ projectId: 'bad project id' }), + /Invalid MEMORY_MEM0_PROJECT_ID/, + ); +}); + +test('mem0 backend can become available only with explicit client skeleton', () => { + const backend = createMem0MemoryBackend({ + enabled: true, + apiKey: 'secret', + projectId: 'memind_project', + client: { + async write() { + return {}; + }, + }, + }); + + assert.equal(backend.isAvailable(), true); + assert.equal(backend.getUnavailableReason(), null); +}); + +test('mem0 HTTP client writes and compacts with bearer auth', async () => { + const calls = []; + const client = createMem0HttpClient({ + apiKey: 'secret', + projectId: 'memind_project', + baseUrl: 'https://mem0.local', + modelProviderKeyId: 'key_mem0', + model: 'gpt-4.1-mini', + modelApiType: 'response', + async fetchImpl(url, options) { + calls.push({ url, headers: options.headers, body: JSON.parse(options.body) }); + return { + ok: true, + async json() { + return { saved: 1, analyzed: 1, memories: 1 }; + }, + }; + }, + }); + + assert.deepEqual(await client.write({ + userId: 'u1', + sessionId: 's1', + messages: [{ text: 'hello' }], + }), { saved: 1, analyzed: 1, memories: 1 }); + assert.deepEqual(await client.compact({ userId: 'u1' }), { + analyzed: 1, + memories: 1, + }); + assert.deepEqual(calls.map((call) => call.url), [ + 'https://mem0.local/v1/memories', + 'https://mem0.local/v1/memories/compact', + ]); + assert.equal(calls[0].headers.authorization, 'Bearer secret'); + assert.equal(calls[0].body.project_id, 'memind_project'); + assert.equal(calls[0].body.model_provider_key_id, 'key_mem0'); + assert.equal(calls[0].body.model, 'gpt-4.1-mini'); + assert.equal(calls[0].body.model_api_type, 'response'); + assert.equal(calls[1].body.model_provider_key_id, 'key_mem0'); +}); diff --git a/memory-v2-neo4j.mjs b/memory-v2-neo4j.mjs new file mode 100644 index 0000000..35eba1f --- /dev/null +++ b/memory-v2-neo4j.mjs @@ -0,0 +1,176 @@ +function normalizeUri(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (!['bolt:', 'neo4j:', 'neo4j+s:', 'neo4j+ssc:'].includes(url.protocol)) { + throw new Error(`Unsupported protocol: ${url.protocol}`); + } + return raw; + } catch (err) { + throw new Error(`Invalid MEMORY_NEO4J_URI: ${err instanceof Error ? err.message : err}`); + } +} + +function normalizeHttpUrl(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Unsupported protocol: ${url.protocol}`); + } + return url.toString().replace(/\/$/, ''); + } catch (err) { + throw new Error(`Invalid MEMORY_NEO4J_HTTP_URL: ${err instanceof Error ? err.message : err}`); + } +} + +function basicAuth(username, password) { + return Buffer.from(`${username}:${password}`).toString('base64'); +} + +export function createNeo4jHttpClient({ + httpUrl, + username, + password, + database = 'neo4j', + fetchImpl = globalThis.fetch, + timeoutMs = 3000, +} = {}) { + const resolvedHttpUrl = normalizeHttpUrl(httpUrl); + if (!resolvedHttpUrl) throw new Error('createNeo4jHttpClient requires MEMORY_NEO4J_HTTP_URL'); + if (!String(username ?? '').trim() || !String(password ?? '').trim()) { + throw new Error('createNeo4jHttpClient requires MEMORY_NEO4J_USER and MEMORY_NEO4J_PASSWORD'); + } + if (typeof fetchImpl !== 'function') throw new Error('createNeo4jHttpClient requires fetch'); + const resolvedDatabase = String(database ?? 'neo4j').trim() || 'neo4j'; + + async function run(statement, parameters = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number(timeoutMs) || 3000); + try { + const response = await fetchImpl( + `${resolvedHttpUrl}/db/${encodeURIComponent(resolvedDatabase)}/tx/commit`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Basic ${basicAuth(username, password)}`, + }, + body: JSON.stringify({ statements: [{ statement, parameters }] }), + signal: controller.signal, + }, + ); + if (!response?.ok) throw new Error(`Neo4j request failed: ${response?.status ?? 'unknown'}`); + const data = await response.json(); + if (Array.isArray(data?.errors) && data.errors.length) { + throw new Error(`Neo4j query failed: ${data.errors[0]?.message ?? 'unknown'}`); + } + return data?.results?.[0]?.data ?? []; + } finally { + clearTimeout(timeout); + } + } + + return { + async resolve(input = {}) { + const rows = await run( + `MATCH (u:MemindUser {id: $userId})-[:HAS_MEMORY_EVENT]->(e:MemoryEvent) + RETURN e.summary AS behaviorSummary + ORDER BY e.created_at DESC + LIMIT $limit`, + { userId: String(input.userId ?? ''), limit: Math.max(1, Math.min(50, Number(input.limit) || 8)) }, + ); + const summaries = rows + .map((row) => row?.row?.[0]) + .filter((item) => typeof item === 'string' && item.trim()); + return { + memories: [], + semanticMemories: [], + behaviorSummary: summaries.join('\n') || null, + activeGoals: [], + }; + }, + async write(input = {}) { + await run( + `MERGE (u:MemindUser {id: $userId}) + CREATE (e:MemoryEvent { + session_id: $sessionId, + summary: $summary, + created_at: datetime() + }) + CREATE (u)-[:HAS_MEMORY_EVENT]->(e) + RETURN id(e)`, + { + userId: String(input.userId ?? ''), + sessionId: String(input.sessionId ?? ''), + summary: JSON.stringify(Array.isArray(input.messages) ? input.messages : []), + }, + ); + return { saved: 1, analyzed: 0, memories: 0 }; + }, + }; +} + +export function createNeo4jMemoryBackend({ + enabled = false, + uri = null, + httpUrl = null, + username = null, + password = null, + client = null, + unavailableReason = null, +} = {}) { + const resolvedUri = normalizeUri(uri); + const resolvedHttpUrl = normalizeHttpUrl(httpUrl); + const hasAuth = Boolean(String(username ?? '').trim() && String(password ?? '').trim()); + const configured = Boolean(enabled && (resolvedUri || resolvedHttpUrl) && hasAuth); + const hasClient = Boolean(client?.resolve && client?.write); + const wired = Boolean(configured && hasClient); + const reason = unavailableReason + ?? (enabled + ? (!(resolvedUri || resolvedHttpUrl) ? 'uri_not_configured' : (!hasAuth ? 'auth_not_configured' : 'client_not_configured')) + : 'not_configured'); + + return { + name: 'neo4j', + category: 'behavior', + role: 'behavior-graph', + flag: 'MEMORY_NEO4J_ENABLED', + unavailableReason: reason, + uri: resolvedUri, + httpUrl: resolvedHttpUrl, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async resolve(input = {}) { + if (!this.isAvailable()) { + return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [] }; + } + const payload = await client.resolve({ ...input, uri: resolvedUri, httpUrl: resolvedHttpUrl }); + return { + memories: Array.isArray(payload?.memories) ? payload.memories : [], + semanticMemories: Array.isArray(payload?.semanticMemories) ? payload.semanticMemories : [], + behaviorSummary: payload?.behaviorSummary ?? null, + activeGoals: Array.isArray(payload?.activeGoals) ? payload.activeGoals : [], + }; + }, + + async write(input = {}) { + if (!this.isAvailable()) return { saved: 0, analyzed: 0, memories: 0 }; + const result = await client.write({ ...input, uri: resolvedUri, httpUrl: resolvedHttpUrl }); + return { + saved: Number(result?.saved ?? 0), + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + }, + }; +} diff --git a/memory-v2-pgvector-backfill.mjs b/memory-v2-pgvector-backfill.mjs new file mode 100644 index 0000000..dc09116 --- /dev/null +++ b/memory-v2-pgvector-backfill.mjs @@ -0,0 +1,158 @@ +const DEFAULT_LIMIT = 100; +const DEFAULT_TABLE = 'memory_embeddings'; +const MAX_LIMIT = 1000; + +function isSafeIdentifier(value) { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? '')); +} + +function quoteIdent(value) { + const normalized = String(value ?? '').trim(); + if (!isSafeIdentifier(normalized)) { + throw new Error(`Invalid PostgreSQL identifier: ${normalized}`); + } + return `"${normalized}"`; +} + +function normalizeLimit(value) { + return Math.max(1, Math.min(MAX_LIMIT, Number(value ?? DEFAULT_LIMIT) || DEFAULT_LIMIT)); +} + +function normalizeCursor(cursor = {}) { + return { + updatedAt: Number(cursor.updatedAt ?? 0) || 0, + id: String(cursor.id ?? ''), + }; +} + +function toPgTimestamp(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) return new Date(0); + return new Date(numeric); +} + +function vectorLiteral(embedding) { + return `[${embedding.map((item) => Number(item)).join(',')}]`; +} + +function normalizeEmbedding(value) { + if (!Array.isArray(value)) return null; + const numbers = value.map((item) => Number(item)); + if (!numbers.length || numbers.some((item) => !Number.isFinite(item))) return null; + return numbers; +} + +function normalizeLegacyMemory(row) { + const id = String(row?.id ?? '').trim(); + const userId = String(row?.user_id ?? '').trim(); + const text = String(row?.memory_text ?? '').trim(); + if (!id || !userId || !text) return null; + return { + id, + userId, + label: String(row?.label ?? 'fact').trim() || 'fact', + text, + evidenceMessageId: row?.evidence_message_id == null ? null : String(row.evidence_message_id), + sourceSessionId: row?.source_session_id == null ? null : String(row.source_session_id), + confidence: row?.confidence == null ? null : Number(row.confidence), + createdAt: Number(row?.created_at ?? 0) || 0, + updatedAt: Number(row?.updated_at ?? 0) || 0, + }; +} + +export async function loadLegacyMemoryBackfillBatch(mysqlPool, { cursor = {}, limit = DEFAULT_LIMIT } = {}) { + if (!mysqlPool?.query) { + throw new Error('loadLegacyMemoryBackfillBatch requires a MySQL pool with query(sql, params)'); + } + const resolvedCursor = normalizeCursor(cursor); + const resolvedLimit = normalizeLimit(limit); + const [rows] = await mysqlPool.query( + `SELECT id, user_id, label, memory_text, evidence_message_id, source_session_id, + confidence, created_at, updated_at + FROM h5_user_memory_items + WHERE status = 'active' + AND (updated_at > ? OR (updated_at = ? AND id > ?)) + ORDER BY updated_at ASC, id ASC + LIMIT ?`, + [resolvedCursor.updatedAt, resolvedCursor.updatedAt, resolvedCursor.id, resolvedLimit], + ); + const memories = (rows ?? []).map((row) => normalizeLegacyMemory(row)).filter(Boolean); + const last = memories.at(-1); + return { + memories, + nextCursor: last ? { updatedAt: last.updatedAt, id: last.id } : resolvedCursor, + hasMore: memories.length === resolvedLimit, + }; +} + +export async function backfillLegacyMemoriesToPgvector({ + mysqlPool, + pgPool = null, + embedMemory = null, + tableName = DEFAULT_TABLE, + cursor = {}, + limit = DEFAULT_LIMIT, + dryRun = true, +} = {}) { + const table = quoteIdent(tableName); + const batch = await loadLegacyMemoryBackfillBatch(mysqlPool, { cursor, limit }); + if (dryRun) { + return { + ok: true, + mode: 'dry-run', + scanned: batch.memories.length, + inserted: 0, + nextCursor: batch.nextCursor, + hasMore: batch.hasMore, + }; + } + if (!pgPool?.query) { + throw new Error('backfill apply requires a PostgreSQL pool with query(sql, params)'); + } + if (typeof embedMemory !== 'function') { + throw new Error('backfill apply requires embedMemory(memory) => number[]'); + } + + let inserted = 0; + for (const memory of batch.memories) { + const embedding = normalizeEmbedding(await embedMemory(memory)); + if (!embedding) continue; + await pgPool.query( + `INSERT INTO ${table} + (user_id, content, embedding, type, source_memory_id, source_session_id, + source_message_id, metadata, created_at, updated_at) + VALUES ($1, $2, $3::vector, $4, $5, $6, $7, $8::jsonb, $9, $10) + ON CONFLICT (source_memory_id) WHERE source_memory_id IS NOT NULL + DO UPDATE SET + content = EXCLUDED.content, + embedding = EXCLUDED.embedding, + type = EXCLUDED.type, + source_session_id = EXCLUDED.source_session_id, + source_message_id = EXCLUDED.source_message_id, + metadata = EXCLUDED.metadata, + updated_at = EXCLUDED.updated_at`, + [ + memory.userId, + memory.text, + vectorLiteral(embedding), + memory.label, + memory.id, + memory.sourceSessionId, + memory.evidenceMessageId, + JSON.stringify({ confidence: memory.confidence }), + toPgTimestamp(memory.createdAt), + toPgTimestamp(memory.updatedAt), + ], + ); + inserted += 1; + } + + return { + ok: true, + mode: 'apply', + scanned: batch.memories.length, + inserted, + nextCursor: batch.nextCursor, + hasMore: batch.hasMore, + }; +} diff --git a/memory-v2-pgvector-backfill.test.mjs b/memory-v2-pgvector-backfill.test.mjs new file mode 100644 index 0000000..1ce1714 --- /dev/null +++ b/memory-v2-pgvector-backfill.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + backfillLegacyMemoriesToPgvector, + loadLegacyMemoryBackfillBatch, +} from './memory-v2-pgvector-backfill.mjs'; + +function createMysqlPool(rows) { + const calls = []; + return { + calls, + async query(sql, params) { + calls.push({ sql, params }); + const [afterUpdatedAt, sameUpdatedAt, afterId, limit] = params; + const filtered = rows + .filter((row) => row.status === 'active') + .filter((row) => row.updated_at > afterUpdatedAt || (row.updated_at === sameUpdatedAt && row.id > afterId)) + .sort((left, right) => left.updated_at - right.updated_at || left.id.localeCompare(right.id)) + .slice(0, limit); + return [filtered]; + }, + }; +} + +const legacyRows = [ + { + id: 'mem-1', + user_id: 'user-1', + label: 'fact', + memory_text: '用户关注 Memory V2 facade', + evidence_message_id: 'msg-1', + source_session_id: 'session-1', + confidence: '0.800', + status: 'active', + created_at: 1000, + updated_at: 2000, + }, + { + id: 'mem-2', + user_id: 'user-1', + label: 'preference', + memory_text: '用户喜欢渐进式架构升级', + evidence_message_id: null, + source_session_id: 'session-2', + confidence: '0.700', + status: 'active', + created_at: 1100, + updated_at: 3000, + }, + { + id: 'mem-archived', + user_id: 'user-1', + label: 'fact', + memory_text: 'archived should not move', + status: 'archived', + created_at: 1200, + updated_at: 4000, + }, +]; + +test('loadLegacyMemoryBackfillBatch reads active memories with cursor ordering', async () => { + const mysqlPool = createMysqlPool(legacyRows); + + const batch = await loadLegacyMemoryBackfillBatch(mysqlPool, { + cursor: { updatedAt: 2000, id: 'mem-1' }, + limit: 10, + }); + + assert.equal(batch.memories.length, 1); + assert.equal(batch.memories[0].id, 'mem-2'); + assert.deepEqual(batch.nextCursor, { updatedAt: 3000, id: 'mem-2' }); + assert.equal(batch.hasMore, false); + assert.match(mysqlPool.calls[0].sql, /FROM h5_user_memory_items/); + assert.deepEqual(mysqlPool.calls[0].params, [2000, 2000, 'mem-1', 10]); +}); + +test('backfillLegacyMemoriesToPgvector dry-run does not embed or write', async () => { + const mysqlPool = createMysqlPool(legacyRows); + let embedded = false; + let written = false; + + const result = await backfillLegacyMemoriesToPgvector({ + mysqlPool, + pgPool: { + async query() { + written = true; + }, + }, + embedMemory: async () => { + embedded = true; + return [0.1, 0.2]; + }, + dryRun: true, + limit: 1, + }); + + assert.equal(result.mode, 'dry-run'); + assert.equal(result.scanned, 1); + assert.equal(result.inserted, 0); + assert.equal(result.hasMore, true); + assert.equal(embedded, false); + assert.equal(written, false); +}); + +test('backfillLegacyMemoriesToPgvector apply embeds and upserts rows', async () => { + const mysqlPool = createMysqlPool(legacyRows); + const pgCalls = []; + + const result = await backfillLegacyMemoriesToPgvector({ + mysqlPool, + pgPool: { + async query(sql, params) { + pgCalls.push({ sql, params }); + return { rows: [] }; + }, + }, + embedMemory: async (memory) => { + assert.match(memory.text, /Memory V2|渐进式/); + return memory.id === 'mem-1' ? [0.1, 0.2] : [0.3, 0.4]; + }, + dryRun: false, + limit: 10, + }); + + assert.equal(result.mode, 'apply'); + assert.equal(result.scanned, 2); + assert.equal(result.inserted, 2); + assert.equal(pgCalls.length, 2); + assert.match(pgCalls[0].sql, /INSERT INTO "memory_embeddings"/); + assert.match(pgCalls[0].sql, /ON CONFLICT \(source_memory_id\)/); + assert.deepEqual(pgCalls[0].params.slice(0, 8), [ + 'user-1', + '用户关注 Memory V2 facade', + '[0.1,0.2]', + 'fact', + 'mem-1', + 'session-1', + 'msg-1', + JSON.stringify({ confidence: 0.8 }), + ]); + assert.ok(pgCalls[0].params[8] instanceof Date); + assert.ok(pgCalls[0].params[9] instanceof Date); +}); + +test('backfillLegacyMemoriesToPgvector apply requires pg pool and embedding function', async () => { + const mysqlPool = createMysqlPool(legacyRows); + + await assert.rejects( + () => backfillLegacyMemoriesToPgvector({ + mysqlPool, + dryRun: false, + embedMemory: async () => [0.1], + }), + /PostgreSQL pool/, + ); + await assert.rejects( + () => backfillLegacyMemoriesToPgvector({ + mysqlPool, + dryRun: false, + pgPool: { async query() {} }, + }), + /embedMemory/, + ); +}); diff --git a/memory-v2-pgvector-schema.mjs b/memory-v2-pgvector-schema.mjs new file mode 100644 index 0000000..2375382 --- /dev/null +++ b/memory-v2-pgvector-schema.mjs @@ -0,0 +1,78 @@ +const DEFAULT_TABLE = 'memory_embeddings'; +const DEFAULT_DIMENSIONS = 1536; + +function isSafeIdentifier(value) { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? '')); +} + +function quoteIdent(value) { + const normalized = String(value ?? '').trim(); + if (!isSafeIdentifier(normalized)) { + throw new Error(`Invalid PostgreSQL identifier: ${normalized}`); + } + return `"${normalized}"`; +} + +function resolveDimensions(value) { + const dimensions = Number(value ?? DEFAULT_DIMENSIONS); + if (!Number.isInteger(dimensions) || dimensions < 1 || dimensions > 16_384) { + throw new Error(`Invalid pgvector dimensions: ${value}`); + } + return dimensions; +} + +export function buildPgvectorMemorySchemaSql({ + tableName = DEFAULT_TABLE, + dimensions = DEFAULT_DIMENSIONS, + createExtension = false, + createVectorIndex = false, +} = {}) { + const table = quoteIdent(tableName); + const dim = resolveDimensions(dimensions); + const statements = []; + if (createExtension) { + statements.push('CREATE EXTENSION IF NOT EXISTS vector'); + } + statements.push(` +CREATE TABLE IF NOT EXISTS ${table} ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + embedding vector(${dim}) NOT NULL, + type TEXT NOT NULL DEFAULT 'fact', + source_memory_id TEXT, + source_session_id TEXT, + source_message_id TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`.trim()); + statements.push( + `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${tableName}_user_created_idx`)} ON ${table} (user_id, created_at DESC)`, + ); + statements.push( + `CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdent(`${tableName}_source_memory_id_uidx`)} ON ${table} (source_memory_id) WHERE source_memory_id IS NOT NULL`, + ); + if (createVectorIndex) { + statements.push( + `CREATE INDEX IF NOT EXISTS ${quoteIdent(`${tableName}_embedding_idx`)} ON ${table} USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`, + ); + } + return statements; +} + +export async function ensurePgvectorMemorySchema(pool, options = {}) { + if (!pool?.query) { + throw new Error('ensurePgvectorMemorySchema requires a PostgreSQL pool with query(sql)'); + } + const statements = buildPgvectorMemorySchemaSql(options); + for (const statement of statements) { + await pool.query(statement); + } + return { + ok: true, + statements: statements.length, + tableName: options.tableName ?? DEFAULT_TABLE, + dimensions: resolveDimensions(options.dimensions), + }; +} diff --git a/memory-v2-pgvector-schema.test.mjs b/memory-v2-pgvector-schema.test.mjs new file mode 100644 index 0000000..38d7d06 --- /dev/null +++ b/memory-v2-pgvector-schema.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildPgvectorMemorySchemaSql, + ensurePgvectorMemorySchema, +} from './memory-v2-pgvector-schema.mjs'; + +test('buildPgvectorMemorySchemaSql creates idempotent table SQL without extension by default', () => { + const statements = buildPgvectorMemorySchemaSql({ + tableName: 'memory_embeddings', + dimensions: 1536, + }); + + assert.equal(statements.length, 3); + assert.doesNotMatch(statements.join('\n'), /CREATE EXTENSION/i); + assert.match(statements[0], /CREATE TABLE IF NOT EXISTS "memory_embeddings"/); + assert.match(statements[0], /embedding vector\(1536\) NOT NULL/); + assert.match(statements[0], /source_memory_id TEXT/); + assert.match(statements[1], /CREATE INDEX IF NOT EXISTS "memory_embeddings_user_created_idx"/); + assert.match(statements[2], /CREATE UNIQUE INDEX IF NOT EXISTS "memory_embeddings_source_memory_id_uidx"/); +}); + +test('buildPgvectorMemorySchemaSql can include extension and vector index for controlled setup', () => { + const statements = buildPgvectorMemorySchemaSql({ + tableName: 'memory_embeddings', + dimensions: 768, + createExtension: true, + createVectorIndex: true, + }); + + assert.equal(statements.length, 5); + assert.equal(statements[0], 'CREATE EXTENSION IF NOT EXISTS vector'); + assert.match(statements[1], /embedding vector\(768\) NOT NULL/); + assert.match(statements[4], /USING ivfflat \(embedding vector_cosine_ops\)/); +}); + +test('buildPgvectorMemorySchemaSql validates identifiers and dimensions', () => { + assert.throws( + () => buildPgvectorMemorySchemaSql({ tableName: 'memory_embeddings;drop table users' }), + /Invalid PostgreSQL identifier/, + ); + assert.throws( + () => buildPgvectorMemorySchemaSql({ dimensions: 0 }), + /Invalid pgvector dimensions/, + ); +}); + +test('ensurePgvectorMemorySchema executes generated statements in order', async () => { + const executed = []; + const result = await ensurePgvectorMemorySchema( + { + async query(sql) { + executed.push(sql); + return { rows: [] }; + }, + }, + { + tableName: 'memory_embeddings', + dimensions: 1536, + createExtension: true, + createVectorIndex: true, + }, + ); + + assert.equal(result.ok, true); + assert.equal(result.statements, 5); + assert.equal(executed[0], 'CREATE EXTENSION IF NOT EXISTS vector'); + assert.match(executed[1], /CREATE TABLE IF NOT EXISTS "memory_embeddings"/); + assert.match(executed[2], /memory_embeddings_user_created_idx/); + assert.match(executed[3], /memory_embeddings_source_memory_id_uidx/); + assert.match(executed[4], /memory_embeddings_embedding_idx/); +}); diff --git a/memory-v2-pgvector-smoke.mjs b/memory-v2-pgvector-smoke.mjs new file mode 100644 index 0000000..2e10dfa --- /dev/null +++ b/memory-v2-pgvector-smoke.mjs @@ -0,0 +1,156 @@ +import { ensurePgvectorMemorySchema } from './memory-v2-pgvector-schema.mjs'; +import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; + +const DEFAULT_TABLE = 'memory_embeddings'; +const DEFAULT_DIMENSIONS = 3; +const SMOKE_SOURCE_ID = 'memory-v2-smoke-test'; +const SMOKE_USER_ID = 'memory-v2-smoke-user'; + +function isSafeIdentifier(value) { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? '')); +} + +function quoteIdent(value) { + const normalized = String(value ?? '').trim(); + if (!isSafeIdentifier(normalized)) { + throw new Error(`Invalid PostgreSQL identifier: ${normalized}`); + } + return `"${normalized}"`; +} + +function vectorLiteral(vector) { + return `[${vector.map((item) => Number(item)).join(',')}]`; +} + +function assertVector(vector, dimensions) { + if (!Array.isArray(vector) || vector.length !== dimensions) { + throw new Error(`Smoke vector must contain ${dimensions} dimensions`); + } + for (const item of vector) { + if (!Number.isFinite(Number(item))) { + throw new Error('Smoke vector contains a non-numeric value'); + } + } +} + +function resolveDimensions(value) { + const dimensions = Number(value ?? DEFAULT_DIMENSIONS); + if (!Number.isInteger(dimensions) || dimensions < 1 || dimensions > 16_384) { + throw new Error(`Invalid pgvector smoke dimensions: ${value}`); + } + return dimensions; +} + +export function buildPgvectorSmokeRows({ dimensions = DEFAULT_DIMENSIONS } = {}) { + const resolvedDimensions = resolveDimensions(dimensions); + const near = Array.from( + { length: resolvedDimensions }, + (_, index) => (index === 0 ? 1 : 0), + ); + const far = Array.from( + { length: resolvedDimensions }, + (_, index) => (index === resolvedDimensions - 1 ? 1 : 0), + ); + const query = [...near]; + return { + userId: SMOKE_USER_ID, + sourceMemoryPrefix: SMOKE_SOURCE_ID, + query, + expectedTopText: 'memory-v2 smoke near vector', + rows: [ + { + sourceMemoryId: `${SMOKE_SOURCE_ID}-near`, + content: 'memory-v2 smoke near vector', + embedding: near, + type: 'smoke', + }, + { + sourceMemoryId: `${SMOKE_SOURCE_ID}-far`, + content: 'memory-v2 smoke far vector', + embedding: far, + type: 'smoke', + }, + ], + }; +} + +export async function runPgvectorMemorySmoke({ + pool, + tableName = DEFAULT_TABLE, + dimensions = DEFAULT_DIMENSIONS, + createSchema = false, + createExtension = false, + cleanup = true, +} = {}) { + if (!pool?.query) { + throw new Error('runPgvectorMemorySmoke requires a PostgreSQL pool with query(sql, params)'); + } + const resolvedDimensions = resolveDimensions(dimensions); + const table = quoteIdent(tableName); + const fixture = buildPgvectorSmokeRows({ dimensions: resolvedDimensions }); + + if (createSchema) { + await ensurePgvectorMemorySchema(pool, { + tableName, + dimensions: resolvedDimensions, + createExtension, + createVectorIndex: false, + }); + } + + try { + for (const row of fixture.rows) { + assertVector(row.embedding, resolvedDimensions); + await pool.query( + `INSERT INTO ${table} + (user_id, content, embedding, type, source_memory_id, metadata) + VALUES ($1, $2, $3::vector, $4, $5, $6::jsonb) + ON CONFLICT (source_memory_id) WHERE source_memory_id IS NOT NULL + DO UPDATE SET + content = EXCLUDED.content, + embedding = EXCLUDED.embedding, + type = EXCLUDED.type, + metadata = EXCLUDED.metadata, + updated_at = NOW()`, + [ + fixture.userId, + row.content, + vectorLiteral(row.embedding), + row.type, + row.sourceMemoryId, + JSON.stringify({ smoke: true }), + ], + ); + } + + const backend = createPgvectorMemoryBackend({ + pool, + enabled: true, + tableName, + defaultLimit: 2, + }); + const resolved = await backend.resolve({ + userId: fixture.userId, + embedding: fixture.query, + limit: 2, + }); + const top = resolved.memories[0] ?? null; + const ok = top?.text === fixture.expectedTopText; + return { + ok, + tableName, + dimensions: resolvedDimensions, + inserted: fixture.rows.length, + expectedTopText: fixture.expectedTopText, + topMemory: top, + memories: resolved.memories, + }; + } finally { + if (cleanup) { + await pool.query( + `DELETE FROM ${table} WHERE source_memory_id LIKE $1`, + [`${fixture.sourceMemoryPrefix}%`], + ); + } + } +} diff --git a/memory-v2-pgvector-smoke.test.mjs b/memory-v2-pgvector-smoke.test.mjs new file mode 100644 index 0000000..7e6b361 --- /dev/null +++ b/memory-v2-pgvector-smoke.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildPgvectorSmokeRows, + runPgvectorMemorySmoke, +} from './memory-v2-pgvector-smoke.mjs'; + +test('buildPgvectorSmokeRows creates deterministic retrieval fixture', () => { + const fixture = buildPgvectorSmokeRows({ dimensions: 4 }); + + assert.equal(fixture.userId, 'memory-v2-smoke-user'); + assert.deepEqual(fixture.query, [1, 0, 0, 0]); + assert.equal(fixture.rows.length, 2); + assert.equal(fixture.rows[0].content, fixture.expectedTopText); + assert.deepEqual(fixture.rows[1].embedding, [0, 0, 0, 1]); +}); + +test('runPgvectorMemorySmoke inserts fixture, resolves nearest memory, and cleans up', async () => { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (/SELECT id, content, type, created_at/.test(sql)) { + return { + rows: [ + { + id: 10, + content: 'memory-v2 smoke near vector', + type: 'smoke', + score: 1, + created_at: '2026-07-03T00:00:00.000Z', + }, + { + id: 11, + content: 'memory-v2 smoke far vector', + type: 'smoke', + score: 0, + created_at: '2026-07-03T00:00:00.000Z', + }, + ], + }; + } + return { rows: [] }; + }, + }; + + const result = await runPgvectorMemorySmoke({ + pool, + tableName: 'memory_embeddings', + dimensions: 3, + }); + + assert.equal(result.ok, true); + assert.equal(result.inserted, 2); + assert.equal(result.topMemory.text, 'memory-v2 smoke near vector'); + assert.equal(calls.filter((call) => /INSERT INTO/.test(call.sql)).length, 2); + assert.equal(calls.some((call) => /DELETE FROM/.test(call.sql)), true); +}); + +test('runPgvectorMemorySmoke can ensure schema before smoke', async () => { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (/SELECT id, content, type, created_at/.test(sql)) { + return { + rows: [{ + id: 1, + content: 'memory-v2 smoke near vector', + type: 'smoke', + }], + }; + } + return { rows: [] }; + }, + }; + + const result = await runPgvectorMemorySmoke({ + pool, + tableName: 'memory_embeddings', + dimensions: 3, + createSchema: true, + createExtension: true, + }); + + assert.equal(result.ok, true); + assert.match(calls[0].sql, /CREATE EXTENSION IF NOT EXISTS vector/); + assert.equal(calls.some((call) => /CREATE TABLE IF NOT EXISTS/.test(call.sql)), true); +}); + +test('runPgvectorMemorySmoke validates identifiers and dimensions', async () => { + await assert.rejects( + () => runPgvectorMemorySmoke({ + pool: { async query() {} }, + tableName: 'memory_embeddings;DROP', + }), + /Invalid PostgreSQL identifier/, + ); + await assert.rejects( + () => runPgvectorMemorySmoke({ + pool: { async query() {} }, + dimensions: 0, + }), + /Invalid pgvector smoke dimensions/, + ); +}); diff --git a/memory-v2-pgvector.mjs b/memory-v2-pgvector.mjs new file mode 100644 index 0000000..c253983 --- /dev/null +++ b/memory-v2-pgvector.mjs @@ -0,0 +1,93 @@ +const DEFAULT_TABLE = 'memory_embeddings'; +const DEFAULT_LIMIT = 8; + +function isSafeIdentifier(value) { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? '')); +} + +function resolveTableName(tableName) { + const normalized = String(tableName ?? DEFAULT_TABLE).trim(); + if (!isSafeIdentifier(normalized)) { + throw new Error(`Invalid pgvector table name: ${normalized}`); + } + return normalized; +} + +function normalizeEmbedding(value) { + if (!Array.isArray(value)) return null; + const numbers = value.map((item) => Number(item)); + if (!numbers.length || numbers.some((item) => !Number.isFinite(item))) return null; + return numbers; +} + +function vectorLiteral(embedding) { + return `[${embedding.join(',')}]`; +} + +function normalizeRow(row) { + const text = String(row?.content ?? row?.memory_text ?? row?.text ?? '').trim(); + if (!text) return null; + return { + id: row?.id == null ? null : String(row.id), + label: row?.type ?? row?.label ?? 'semantic', + text, + score: row?.score == null ? null : Number(row.score), + createdAt: row?.created_at ?? row?.createdAt ?? null, + }; +} + +export function createPgvectorMemoryBackend({ + pool = null, + enabled = false, + tableName = DEFAULT_TABLE, + embedQuery = null, + defaultLimit = DEFAULT_LIMIT, + unavailableReason = 'not_configured', +} = {}) { + const resolvedTableName = resolveTableName(tableName); + + async function resolveEmbedding(input) { + const explicit = normalizeEmbedding(input?.embedding); + if (explicit) return explicit; + if (typeof embedQuery !== 'function' || !input?.query) return null; + return normalizeEmbedding(await embedQuery(input.query, input)); + } + + return { + name: 'pgvector', + category: 'semantic', + role: 'primary-vector-store', + flag: 'MEMORY_VECTOR_ENABLED', + unavailableReason, + + isAvailable() { + return Boolean(enabled && pool?.query); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : unavailableReason; + }, + + async resolve(input = {}) { + if (!this.isAvailable()) return { memories: [], semanticMemories: [] }; + const userId = String(input.userId ?? '').trim(); + if (!userId) return { memories: [], semanticMemories: [] }; + const embedding = await resolveEmbedding(input); + if (!embedding) return { memories: [], semanticMemories: [] }; + const limit = Math.max(1, Math.min(50, Number(input.limit ?? defaultLimit) || defaultLimit)); + const sql = ` + SELECT id, content, type, created_at, 1 - (embedding <=> $2::vector) AS score + FROM ${resolvedTableName} + WHERE user_id = $1 + ORDER BY embedding <=> $2::vector + LIMIT $3 + `; + const result = await pool.query(sql, [userId, vectorLiteral(embedding), limit]); + const memories = (result?.rows ?? []).map((row) => normalizeRow(row)).filter(Boolean); + return { + semanticMemories: memories.map((item) => item.text), + memories, + }; + }, + }; +} diff --git a/memory-v2-pgvector.test.mjs b/memory-v2-pgvector.test.mjs new file mode 100644 index 0000000..e29713f --- /dev/null +++ b/memory-v2-pgvector.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMemoryV2 } from './memory-v2.mjs'; +import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; + +test('pgvector backend is disabled by default and does not query storage', async () => { + let queried = false; + const backend = createPgvectorMemoryBackend({ + pool: { + async query() { + queried = true; + return { rows: [] }; + }, + }, + }); + + assert.equal(backend.isAvailable(), false); + assert.deepEqual(await backend.resolve({ + userId: 'user-1', + embedding: [0.1, 0.2], + }), { + memories: [], + semanticMemories: [], + }); + assert.equal(queried, false); +}); + +test('pgvector backend returns empty semantic result when embedding is unavailable', async () => { + let queried = false; + const backend = createPgvectorMemoryBackend({ + enabled: true, + pool: { + async query() { + queried = true; + return { rows: [] }; + }, + }, + }); + + const result = await backend.resolve({ + userId: 'user-1', + query: 'memory-chain', + }); + + assert.deepEqual(result, { + memories: [], + semanticMemories: [], + }); + assert.equal(queried, false); +}); + +test('pgvector backend performs parameterized vector lookup when explicitly enabled', async () => { + const queries = []; + const backend = createPgvectorMemoryBackend({ + enabled: true, + tableName: 'memory_embeddings', + pool: { + async query(sql, params) { + queries.push({ sql, params }); + return { + rows: [ + { + id: 7, + content: '用户关注 Memory V2 的 facade 边界', + type: 'fact', + score: '0.87', + created_at: '2026-07-02T00:00:00.000Z', + }, + ], + }; + }, + }, + embedQuery: async (query) => { + assert.equal(query, 'memory-chain'); + return [0.25, 0.5, 0.75]; + }, + }); + + assert.equal(backend.isAvailable(), true); + const result = await backend.resolve({ + userId: 'user-1', + query: 'memory-chain', + limit: 5, + }); + + assert.equal(queries.length, 1); + assert.match(queries[0].sql, /FROM memory_embeddings/); + assert.deepEqual(queries[0].params, ['user-1', '[0.25,0.5,0.75]', 5]); + assert.deepEqual(result.semanticMemories, ['用户关注 Memory V2 的 facade 边界']); + assert.deepEqual(result.memories, [ + { + id: '7', + label: 'fact', + text: '用户关注 Memory V2 的 facade 边界', + score: 0.87, + createdAt: '2026-07-02T00:00:00.000Z', + }, + ]); +}); + +test('pgvector backend validates table names before building SQL', () => { + assert.throws( + () => createPgvectorMemoryBackend({ tableName: 'memory_embeddings;DROP TABLE users' }), + /Invalid pgvector table name/, + ); +}); + +test('Memory V2 can expose pgvector as unavailable plugin without selecting it', async () => { + const memory = createMemoryV2({ + logger: { warn() {} }, + backends: [ + createPgvectorMemoryBackend({ enabled: false }), + { + name: 'legacy-conversation-memory', + async resolve() { + return { memories: [{ label: 'fact', text: 'legacy survives' }] }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_VECTOR_ENABLED: '1', + }, + }); + + const status = memory.getStatus(); + const result = await memory.resolve({ userId: 'user-1', query: 'memory-chain' }); + + assert.equal(status.vectorEnabled, true); + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(status.backends.find((item) => item.name === 'pgvector')?.available, false); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy survives' }]); +}); diff --git a/memory-v2-plugin-backends.mjs b/memory-v2-plugin-backends.mjs new file mode 100644 index 0000000..6a944e4 --- /dev/null +++ b/memory-v2-plugin-backends.mjs @@ -0,0 +1,118 @@ +export const MEMORY_V2_PLUGIN_BACKEND_SPECS = [ + { + name: 'pgvector', + category: 'semantic', + role: 'primary-vector-store', + capabilities: ['resolve'], + flag: 'MEMORY_VECTOR_ENABLED', + }, + { + name: 'qdrant', + category: 'semantic', + role: 'scale-out-vector-store', + capabilities: ['resolve'], + flag: 'MEMORY_QDRANT_ENABLED', + }, + { + name: 'weaviate', + category: 'semantic', + role: 'knowledge-graph-fusion', + capabilities: ['resolve'], + flag: 'MEMORY_WEAVIATE_ENABLED', + }, + { + name: 'mem0', + category: 'extraction', + role: 'automatic-memory-generation', + capabilities: ['write', 'compact'], + flag: 'MEMORY_MEM0_ENABLED', + }, + { + name: 'letta', + category: 'lifecycle', + role: 'long-short-term-memory-os', + capabilities: ['resolve', 'write', 'compact'], + flag: 'MEMORY_LETTA_ENABLED', + }, + { + name: 'neo4j', + category: 'behavior', + role: 'behavior-graph', + capabilities: ['resolve', 'write'], + flag: 'MEMORY_NEO4J_ENABLED', + }, + { + name: 'redis-streams', + category: 'behavior', + role: 'event-tracking', + capabilities: ['write'], + flag: 'MEMORY_REDIS_STREAMS_ENABLED', + }, + { + name: 'langgraph', + category: 'policy', + role: 'routing-reasoning-policy', + capabilities: ['resolve'], + flag: 'MEMORY_LANGGRAPH_ENABLED', + }, +]; + +function normalizeSpec(spec) { + const name = String(spec?.name ?? '').trim(); + if (!name) throw new Error('Memory V2 plugin backend requires a name'); + return { + name, + category: String(spec?.category ?? 'plugin'), + role: String(spec?.role ?? 'optional-plugin'), + capabilities: Array.isArray(spec?.capabilities) ? spec.capabilities : [], + flag: spec?.flag == null ? null : String(spec.flag), + }; +} + +export function createUnavailableMemoryBackend(spec, { reason = 'not_configured' } = {}) { + const normalized = normalizeSpec(spec); + const backend = { + name: normalized.name, + category: normalized.category, + role: normalized.role, + flag: normalized.flag, + unavailableReason: reason, + + isAvailable() { + return false; + }, + + getUnavailableReason() { + return reason; + }, + }; + + if (normalized.capabilities.includes('resolve')) { + backend.resolve = async () => ({ + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }); + } + if (normalized.capabilities.includes('write')) { + backend.write = async () => ({ saved: 0, analyzed: 0, memories: 0 }); + } + if (normalized.capabilities.includes('compact')) { + backend.compact = async () => ({ analyzed: 0, memories: 0 }); + } + + return backend; +} + +export function createMemoryV2PluginBackends({ + specs = MEMORY_V2_PLUGIN_BACKEND_SPECS, + exclude = [], + reason = 'not_configured', +} = {}) { + const excluded = new Set(exclude); + return specs + .map((spec) => normalizeSpec(spec)) + .filter((spec) => !excluded.has(spec.name)) + .map((spec) => createUnavailableMemoryBackend(spec, { reason })); +} diff --git a/memory-v2-plugin-backends.test.mjs b/memory-v2-plugin-backends.test.mjs new file mode 100644 index 0000000..d9e3e04 --- /dev/null +++ b/memory-v2-plugin-backends.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MEMORY_V2_PLUGIN_BACKEND_SPECS, + createMemoryV2PluginBackends, + createUnavailableMemoryBackend, +} from './memory-v2-plugin-backends.mjs'; +import { createMemoryV2 } from './memory-v2.mjs'; + +function silentLogger() { + return { warn() {} }; +} + +test('Memory V2 plugin specs cover optional future backend families', () => { + assert.deepEqual( + MEMORY_V2_PLUGIN_BACKEND_SPECS.map((spec) => [spec.name, spec.category]), + [ + ['pgvector', 'semantic'], + ['qdrant', 'semantic'], + ['weaviate', 'semantic'], + ['mem0', 'extraction'], + ['letta', 'lifecycle'], + ['neo4j', 'behavior'], + ['redis-streams', 'behavior'], + ['langgraph', 'policy'], + ], + ); +}); + +test('createUnavailableMemoryBackend exposes declared capabilities without availability', async () => { + const backend = createUnavailableMemoryBackend({ + name: 'mem0', + category: 'extraction', + role: 'automatic-memory-generation', + capabilities: ['write', 'compact'], + flag: 'MEMORY_MEM0_ENABLED', + }); + + assert.equal(backend.name, 'mem0'); + assert.equal(backend.category, 'extraction'); + assert.equal(backend.role, 'automatic-memory-generation'); + assert.equal(backend.flag, 'MEMORY_MEM0_ENABLED'); + assert.equal(backend.isAvailable(), false); + assert.equal(backend.getUnavailableReason(), 'not_configured'); + assert.equal(typeof backend.resolve, 'undefined'); + assert.deepEqual(await backend.write({}), { saved: 0, analyzed: 0, memories: 0 }); + assert.deepEqual(await backend.compact({}), { analyzed: 0, memories: 0 }); +}); + +test('createMemoryV2PluginBackends can exclude a real injected backend', () => { + const backends = createMemoryV2PluginBackends({ exclude: ['pgvector'] }); + + assert.equal(backends.some((backend) => backend.name === 'pgvector'), false); + assert.equal(backends.some((backend) => backend.name === 'qdrant'), true); +}); + +test('Memory V2 default status lists future plugin slots without selecting them', () => { + const memory = createMemoryV2({ + logger: silentLogger(), + legacyMemoryService: { + async listMemories() { + return []; + }, + }, + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'legacy', + }, + }); + + const status = memory.getStatus(); + const byName = new Map(status.backends.map((backend) => [backend.name, backend])); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(byName.get('legacy-conversation-memory').available, true); + assert.deepEqual( + [...byName.keys()], + [ + 'legacy-conversation-memory', + 'pgvector', + 'qdrant', + 'weaviate', + 'mem0', + 'letta', + 'neo4j', + 'redis-streams', + 'langgraph', + ], + ); + assert.deepEqual(byName.get('qdrant'), { + name: 'qdrant', + available: false, + supports: { + resolve: true, + write: false, + compact: false, + }, + category: 'semantic', + role: 'scale-out-vector-store', + flag: 'MEMORY_QDRANT_ENABLED', + reason: 'not_configured', + }); +}); + +test('Memory V2 falls back to legacy when a future plugin is configured but unavailable', async () => { + const memory = createMemoryV2({ + logger: silentLogger(), + legacyMemoryService: { + async listMemories() { + return [{ label: 'fact', text: 'legacy remains the safe path' }]; + }, + }, + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'qdrant', + }, + }); + + const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory'); + assert.equal(result.source, 'legacy-conversation-memory'); + assert.deepEqual(result.memories, [ + { label: 'fact', text: 'legacy remains the safe path' }, + ]); +}); diff --git a/memory-v2-qdrant.mjs b/memory-v2-qdrant.mjs new file mode 100644 index 0000000..52ed2a8 --- /dev/null +++ b/memory-v2-qdrant.mjs @@ -0,0 +1,211 @@ +const DEFAULT_COLLECTION = 'memind_memory'; +const DEFAULT_LIMIT = 8; +const DEFAULT_TIMEOUT_MS = 3000; + +function normalizeUrl(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Unsupported Qdrant URL protocol: ${url.protocol}`); + } + return url.toString().replace(/\/$/, ''); + } catch (err) { + throw new Error(`Invalid MEMORY_QDRANT_URL: ${err instanceof Error ? err.message : err}`); + } +} + +function normalizeCollection(value) { + const collection = String(value ?? DEFAULT_COLLECTION).trim(); + if (!/^[a-zA-Z0-9_-]+$/.test(collection)) { + throw new Error(`Invalid Qdrant collection name: ${collection}`); + } + return collection; +} + +function normalizeVector(value) { + if (!Array.isArray(value)) return null; + const vector = value.map((item) => Number(item)); + if (!vector.length || vector.some((item) => !Number.isFinite(item))) return null; + return vector; +} + +function normalizePayload(payload = {}) { + const text = String( + payload.content ?? payload.text ?? payload.memory_text ?? payload.memoryText ?? '', + ).trim(); + if (!text) return null; + return { + id: payload.id == null ? null : String(payload.id), + label: payload.type ?? payload.label ?? 'semantic', + text, + createdAt: payload.created_at ?? payload.createdAt ?? null, + }; +} + +function normalizePoint(point = {}) { + const payload = normalizePayload(point.payload ?? {}); + if (!payload) return null; + return { + id: point.id == null ? payload.id : String(point.id), + label: payload.label, + text: payload.text, + score: point.score == null ? null : Number(point.score), + createdAt: payload.createdAt, + }; +} + +function timeoutSignal(timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + clear() { + clearTimeout(timeout); + }, + }; +} + +export function createQdrantHttpClient({ + url, + apiKey = null, + fetchImpl = globalThis.fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + const resolvedUrl = normalizeUrl(url); + if (!resolvedUrl) throw new Error('createQdrantHttpClient requires MEMORY_QDRANT_URL'); + if (typeof fetchImpl !== 'function') { + throw new Error('createQdrantHttpClient requires fetch'); + } + + async function requestJson(path, body) { + const timeout = timeoutSignal(Number(timeoutMs)); + try { + const response = await fetchImpl(`${resolvedUrl}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(apiKey ? { 'api-key': String(apiKey) } : {}), + }, + body: JSON.stringify(body), + ...(timeout?.signal ? { signal: timeout.signal } : {}), + }); + if (!response?.ok) { + throw new Error(`Qdrant request failed: ${response?.status ?? 'unknown'}`); + } + return response.json(); + } finally { + timeout?.clear(); + } + } + + return { + async search({ collection, vector, userId, limit = DEFAULT_LIMIT } = {}) { + const resolvedCollection = normalizeCollection(collection); + const resolvedVector = normalizeVector(vector); + if (!resolvedVector) return []; + const body = { + vector: resolvedVector, + limit: Math.max(1, Math.min(50, Number(limit) || DEFAULT_LIMIT)), + with_payload: true, + filter: userId + ? { + must: [ + { + key: 'user_id', + match: { value: String(userId) }, + }, + ], + } + : undefined, + }; + const data = await requestJson(`/collections/${resolvedCollection}/points/search`, body); + return Array.isArray(data?.result) ? data.result : []; + }, + }; +} + +export function createQdrantMemoryBackend({ + enabled = false, + url = null, + collection = DEFAULT_COLLECTION, + client = null, + embedQuery = null, + defaultLimit = DEFAULT_LIMIT, + unavailableReason = null, +} = {}) { + const resolvedUrl = normalizeUrl(url); + const resolvedCollection = normalizeCollection(collection); + const configured = Boolean(enabled && resolvedUrl && resolvedCollection); + const hasClient = Boolean(client?.search); + const hasEmbedding = typeof embedQuery === 'function'; + const wired = Boolean(configured && hasClient && hasEmbedding); + const reason = unavailableReason + ?? (enabled + ? ( + !resolvedUrl + ? 'url_not_configured' + : (!hasClient ? 'client_not_configured' : 'embedding_module_not_configured') + ) + : 'not_configured'); + + async function resolveVector(input) { + const explicit = normalizeVector(input?.embedding); + if (explicit) return explicit; + if (typeof embedQuery !== 'function' || !input?.query) return null; + return normalizeVector(await embedQuery(input.query, input)); + } + + return { + name: 'qdrant', + category: 'semantic', + role: 'scale-out-vector-store', + flag: 'MEMORY_QDRANT_ENABLED', + unavailableReason: reason, + url: resolvedUrl, + collection: resolvedCollection, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async resolve(input = {}) { + if (!this.isAvailable()) { + return { + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }; + } + const vector = await resolveVector(input); + if (!vector) { + return { + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }; + } + const points = await client.search({ + collection: resolvedCollection, + vector, + userId: input.userId, + limit: input.limit ?? defaultLimit, + }); + const memories = points.map((point) => normalizePoint(point)).filter(Boolean); + return { + memories, + semanticMemories: memories.map((memory) => memory.text), + behaviorSummary: null, + activeGoals: [], + }; + }, + }; +} diff --git a/memory-v2-qdrant.test.mjs b/memory-v2-qdrant.test.mjs new file mode 100644 index 0000000..a4222e0 --- /dev/null +++ b/memory-v2-qdrant.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { inspectMemoryV2Backend } from './memory-v2-backend-contract.mjs'; +import { createQdrantHttpClient, createQdrantMemoryBackend } from './memory-v2-qdrant.mjs'; + +test('qdrant backend is disabled by default and passes contract gate', async () => { + const backend = createQdrantMemoryBackend(); + const report = inspectMemoryV2Backend(backend); + + assert.equal(backend.name, 'qdrant'); + assert.equal(backend.category, 'semantic'); + assert.equal(backend.role, 'scale-out-vector-store'); + assert.equal(backend.flag, 'MEMORY_QDRANT_ENABLED'); + assert.equal(backend.isAvailable(), false); + assert.equal(backend.getUnavailableReason(), 'not_configured'); + assert.equal(report.ok, true); + assert.deepEqual(await backend.resolve({}), { + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }); +}); + +test('qdrant backend reports client_not_configured when configured without client', () => { + const backend = createQdrantMemoryBackend({ + enabled: true, + url: 'https://qdrant.local:6333', + collection: 'memind_memory', + }); + + assert.equal(backend.isAvailable(), false); + assert.equal(backend.url, 'https://qdrant.local:6333'); + assert.equal(backend.collection, 'memind_memory'); + assert.equal(backend.getUnavailableReason(), 'client_not_configured'); +}); + +test('qdrant backend validates url and collection name', () => { + assert.throws( + () => createQdrantMemoryBackend({ enabled: true, url: 'ftp://qdrant.local' }), + /Unsupported Qdrant URL protocol/, + ); + assert.throws( + () => createQdrantMemoryBackend({ collection: 'bad collection name' }), + /Invalid Qdrant collection name/, + ); +}); + +test('qdrant backend requires client and embedding function to become available', async () => { + const backend = createQdrantMemoryBackend({ + enabled: true, + url: 'http://127.0.0.1:6333', + collection: 'memind_memory', + client: { + async search() { + return []; + }, + }, + async embedQuery() { + return [0.1, 0.2, 0.3]; + }, + }); + + assert.equal(backend.isAvailable(), true); + assert.equal(backend.getUnavailableReason(), null); + assert.deepEqual(await backend.resolve({ userId: 'u1', query: 'hello' }), { + memories: [], + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + }); +}); + +test('qdrant backend resolves memories through read-only client search', async () => { + const calls = []; + const backend = createQdrantMemoryBackend({ + enabled: true, + url: 'http://127.0.0.1:6333', + collection: 'memind_memory', + client: { + async search(input) { + calls.push(input); + return [ + { + id: 'p1', + score: 0.9, + payload: { + content: 'qdrant semantic memory', + type: 'fact', + created_at: '2026-07-03T00:00:00.000Z', + }, + }, + ]; + }, + }, + async embedQuery(query) { + assert.equal(query, 'memory-chain'); + return [0.1, 0.2, 0.3]; + }, + }); + + const result = await backend.resolve({ userId: 'u1', query: 'memory-chain', limit: 5 }); + + assert.equal(result.memories[0].text, 'qdrant semantic memory'); + assert.deepEqual(result.semanticMemories, ['qdrant semantic memory']); + assert.deepEqual(calls, [{ + collection: 'memind_memory', + vector: [0.1, 0.2, 0.3], + userId: 'u1', + limit: 5, + }]); +}); + +test('createQdrantHttpClient sends read-only search request with api key and filter', async () => { + const calls = []; + const client = createQdrantHttpClient({ + url: 'http://127.0.0.1:6333/', + apiKey: 'secret', + timeoutMs: 1000, + async fetchImpl(url, options) { + calls.push({ url, options }); + return { + ok: true, + async json() { + return { + result: [{ + id: 'p1', + score: 0.8, + payload: { content: 'hello' }, + }], + }; + }, + }; + }, + }); + + const result = await client.search({ + collection: 'memind_memory', + vector: [1, 0, 0], + userId: 'u1', + limit: 3, + }); + + assert.equal(result.length, 1); + assert.equal(calls[0].url, 'http://127.0.0.1:6333/collections/memind_memory/points/search'); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.headers['api-key'], 'secret'); + assert.deepEqual(JSON.parse(calls[0].options.body), { + vector: [1, 0, 0], + limit: 3, + with_payload: true, + filter: { + must: [{ + key: 'user_id', + match: { value: 'u1' }, + }], + }, + }); +}); + +test('createQdrantHttpClient turns non-ok responses into errors', async () => { + const client = createQdrantHttpClient({ + url: 'http://127.0.0.1:6333', + async fetchImpl() { + return { ok: false, status: 500 }; + }, + }); + + await assert.rejects( + () => client.search({ collection: 'memind_memory', vector: [1, 0, 0] }), + /Qdrant request failed: 500/, + ); +}); diff --git a/memory-v2-redis-streams.mjs b/memory-v2-redis-streams.mjs new file mode 100644 index 0000000..781c045 --- /dev/null +++ b/memory-v2-redis-streams.mjs @@ -0,0 +1,111 @@ +function normalizeUrl(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (!['redis:', 'rediss:'].includes(url.protocol)) { + throw new Error(`Unsupported protocol: ${url.protocol}`); + } + return raw; + } catch (err) { + throw new Error(`Invalid MEMORY_REDIS_STREAMS_URL: ${err instanceof Error ? err.message : err}`); + } +} + +function normalizeStream(value) { + const raw = String(value ?? 'memind:memory-events').trim(); + if (!/^[a-zA-Z0-9:_-]+$/.test(raw)) { + throw new Error(`Invalid MEMORY_REDIS_STREAMS_STREAM: ${raw}`); + } + return raw; +} + +function flattenFields(input = {}) { + return { + user_id: String(input.userId ?? ''), + session_id: String(input.sessionId ?? ''), + event_type: String(input.eventType ?? 'memory.write'), + messages_json: JSON.stringify(Array.isArray(input.messages) ? input.messages : []), + created_at: new Date().toISOString(), + }; +} + +export async function createRedisStreamsClient({ + url, + stream = 'memind:memory-events', + importRedis = (specifier) => import(specifier), +} = {}) { + const resolvedUrl = normalizeUrl(url); + const resolvedStream = normalizeStream(stream); + if (!resolvedUrl) throw new Error('createRedisStreamsClient requires MEMORY_REDIS_STREAMS_URL'); + const imported = await importRedis('redis'); + const createClient = imported?.createClient ?? imported?.default?.createClient; + if (typeof createClient !== 'function') throw new Error('redis module does not export createClient'); + const client = createClient({ url: resolvedUrl }); + let connected = false; + + async function ensureConnected() { + if (connected) return; + await client.connect(); + connected = true; + } + + return { + async write(input = {}) { + await ensureConnected(); + await client.xAdd(resolvedStream, '*', flattenFields(input)); + return { saved: 1, analyzed: 0, memories: 0 }; + }, + async close() { + if (!connected) return; + connected = false; + await client.quit?.(); + }, + }; +} + +export function createRedisStreamsMemoryBackend({ + enabled = false, + url = null, + stream = 'memind:memory-events', + client = null, + unavailableReason = null, +} = {}) { + const resolvedUrl = normalizeUrl(url); + const resolvedStream = normalizeStream(stream); + const configured = Boolean(enabled && resolvedUrl); + const hasClient = Boolean(client?.write); + const wired = Boolean(configured && hasClient); + const reason = unavailableReason + ?? (enabled + ? (!resolvedUrl ? 'url_not_configured' : 'client_not_configured') + : 'not_configured'); + + return { + name: 'redis-streams', + category: 'behavior', + role: 'event-tracking', + flag: 'MEMORY_REDIS_STREAMS_ENABLED', + unavailableReason: reason, + url: resolvedUrl, + stream: resolvedStream, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async write(input = {}) { + if (!this.isAvailable()) return { saved: 0, analyzed: 0, memories: 0 }; + const result = await client.write({ ...input, url: resolvedUrl, stream: resolvedStream }); + return { + saved: Number(result?.saved ?? 0), + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + }, + }; +} diff --git a/memory-v2-runtime.mjs b/memory-v2-runtime.mjs new file mode 100644 index 0000000..945c24c --- /dev/null +++ b/memory-v2-runtime.mjs @@ -0,0 +1,550 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createLangGraphHttpClient, createLangGraphMemoryBackend } from './memory-v2-langgraph.mjs'; +import { createLegacyMemoryBackend, createMemoryV2, resolveMemoryV2Policy } from './memory-v2.mjs'; +import { createLettaHttpClient, createLettaMemoryBackend } from './memory-v2-letta.mjs'; +import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs'; +import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs'; +import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs'; +import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; +import { createQdrantHttpClient, createQdrantMemoryBackend } from './memory-v2-qdrant.mjs'; +import { createRedisStreamsClient, createRedisStreamsMemoryBackend } from './memory-v2-redis-streams.mjs'; +import { createWeaviateHttpClient, createWeaviateMemoryBackend } from './memory-v2-weaviate.mjs'; + +const DEFAULT_PGVECTOR_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL'; + +function readFlag(env, key, fallback = false) { + const raw = env?.[key]; + if (raw == null || raw === '') return fallback; + const normalized = String(raw).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function resolveModuleSpecifier(specifier) { + const value = String(specifier ?? '').trim(); + if (!value) return null; + if (value.startsWith('.') || value.startsWith('/')) { + return pathToFileURL(path.resolve(value)).href; + } + return value; +} + +async function loadEmbeddingModule(moduleSpecifier, importModule) { + const resolved = resolveModuleSpecifier(moduleSpecifier); + if (!resolved) return null; + const imported = await importModule(resolved); + const fn = imported?.embedQuery ?? imported?.default; + if (typeof fn !== 'function') { + throw new Error( + `Memory V2 pgvector embedding module must export embedQuery or default: ${moduleSpecifier}`, + ); + } + return fn; +} + +async function createPgPool(connectionString, { importPg, max }) { + const imported = await importPg('pg'); + const PgPool = imported?.Pool ?? imported?.default?.Pool; + if (typeof PgPool !== 'function') { + throw new Error('pg module does not export Pool'); + } + return new PgPool({ connectionString, max }); +} + +export async function createMemoryV2Runtime({ + legacyMemoryService = null, + env = process.env, + logger = console, + importPg = (specifier) => import(specifier), + importRedis = (specifier) => import(specifier), + importModule = (specifier) => import(specifier), + fetchImpl = globalThis.fetch, + lettaClient = null, + weaviateClient = null, + neo4jClient = null, + redisStreamsClient = null, + langGraphClient = null, +} = {}) { + const vectorEnabled = readFlag(env, 'MEMORY_VECTOR_ENABLED', false); + const pgvectorEnabled = readFlag(env, 'MEMORY_PGVECTOR_ENABLED', vectorEnabled); + const connectionString = String(env?.[DEFAULT_PGVECTOR_URL_ENV] ?? '').trim(); + const embeddingModule = String(env?.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? '').trim(); + const backends = [createLegacyMemoryBackend(legacyMemoryService)]; + const closers = []; + + let pgvectorBackend = null; + if (pgvectorEnabled && connectionString) { + try { + const embedQuery = await loadEmbeddingModule(embeddingModule, importModule); + if (!embedQuery) { + pgvectorBackend = createPgvectorMemoryBackend({ + enabled: false, + tableName: env?.MEMORY_PGVECTOR_TABLE, + unavailableReason: 'embedding_module_not_configured', + }); + } else { + const pool = await createPgPool(connectionString, { + importPg, + max: Math.max(1, Number(env?.MEMORY_PGVECTOR_POOL_MAX ?? 5) || 5), + }); + closers.push(() => pool.end?.()); + pgvectorBackend = createPgvectorMemoryBackend({ + pool, + enabled: pgvectorEnabled, + tableName: env?.MEMORY_PGVECTOR_TABLE, + embedQuery, + defaultLimit: Number(env?.MEMORY_PGVECTOR_LIMIT ?? 8) || 8, + }); + } + } catch (err) { + logger?.warn?.( + `[memory-v2] pgvector backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + pgvectorBackend = createPgvectorMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + + if (pgvectorBackend) backends.push(pgvectorBackend); + let qdrantBackend = null; + const qdrantEnabled = readFlag(env, 'MEMORY_QDRANT_ENABLED', false); + const qdrantUrl = String(env?.MEMORY_QDRANT_URL ?? '').trim(); + const qdrantCollection = String(env?.MEMORY_QDRANT_COLLECTION ?? '').trim(); + const qdrantEmbeddingModule = String(env?.MEMORY_QDRANT_EMBEDDING_MODULE ?? '').trim(); + if (qdrantEnabled || qdrantUrl || qdrantCollection) { + try { + const qdrantEmbedQuery = await loadEmbeddingModule(qdrantEmbeddingModule, importModule); + const qdrantClient = qdrantEnabled && qdrantUrl && qdrantEmbedQuery + ? createQdrantHttpClient({ + url: qdrantUrl, + apiKey: env?.MEMORY_QDRANT_API_KEY, + fetchImpl, + timeoutMs: Number(env?.MEMORY_QDRANT_TIMEOUT_MS ?? 3000) || 3000, + }) + : null; + qdrantBackend = createQdrantMemoryBackend({ + enabled: qdrantEnabled, + url: qdrantUrl, + collection: qdrantCollection || undefined, + client: qdrantClient, + embedQuery: qdrantEmbedQuery, + defaultLimit: Number(env?.MEMORY_QDRANT_LIMIT ?? 8) || 8, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] qdrant backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + qdrantBackend = createQdrantMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (qdrantBackend) backends.push(qdrantBackend); + let weaviateBackend = null; + const weaviateEnabled = readFlag(env, 'MEMORY_WEAVIATE_ENABLED', false); + const weaviateUrl = String(env?.MEMORY_WEAVIATE_URL ?? '').trim(); + const weaviateCollection = String(env?.MEMORY_WEAVIATE_COLLECTION ?? '').trim(); + const weaviateEmbeddingModule = String(env?.MEMORY_WEAVIATE_EMBEDDING_MODULE ?? '').trim(); + if (weaviateEnabled || weaviateUrl || weaviateCollection) { + try { + const weaviateEmbedQuery = await loadEmbeddingModule(weaviateEmbeddingModule, importModule); + const resolvedWeaviateClient = weaviateClient ?? ( + weaviateEnabled && weaviateUrl && weaviateEmbedQuery + ? createWeaviateHttpClient({ + url: weaviateUrl, + apiKey: env?.MEMORY_WEAVIATE_API_KEY, + fetchImpl, + timeoutMs: Number(env?.MEMORY_WEAVIATE_TIMEOUT_MS ?? 3000) || 3000, + }) + : null + ); + weaviateBackend = createWeaviateMemoryBackend({ + enabled: weaviateEnabled, + url: weaviateUrl, + collection: weaviateCollection || undefined, + client: resolvedWeaviateClient, + embedQuery: weaviateEmbedQuery, + defaultLimit: Number(env?.MEMORY_WEAVIATE_LIMIT ?? 8) || 8, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] weaviate backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + weaviateBackend = createWeaviateMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (weaviateBackend) backends.push(weaviateBackend); + let mem0Backend = null; + const mem0Enabled = readFlag(env, 'MEMORY_MEM0_ENABLED', false); + const mem0ApiKey = String(env?.MEMORY_MEM0_API_KEY ?? '').trim(); + const mem0ProjectId = String(env?.MEMORY_MEM0_PROJECT_ID ?? '').trim(); + const mem0BaseUrl = String(env?.MEMORY_MEM0_BASE_URL ?? 'https://api.mem0.ai').trim(); + const mem0ModelProviderKeyId = String(env?.MEMORY_MEM0_MODEL_PROVIDER_KEY_ID ?? '').trim(); + const mem0Model = String(env?.MEMORY_MEM0_MODEL ?? '').trim(); + const mem0ModelApiType = String(env?.MEMORY_MEM0_MODEL_API ?? '').trim(); + if (mem0Enabled || mem0ApiKey || mem0ProjectId) { + try { + const mem0Client = mem0Enabled && mem0ApiKey + ? createMem0HttpClient({ + apiKey: mem0ApiKey, + projectId: mem0ProjectId || undefined, + baseUrl: mem0BaseUrl, + writePath: env?.MEMORY_MEM0_WRITE_PATH || undefined, + compactPath: env?.MEMORY_MEM0_COMPACT_PATH || undefined, + modelProviderKeyId: mem0ModelProviderKeyId || undefined, + model: mem0Model || undefined, + modelApiType: mem0ModelApiType || undefined, + fetchImpl, + timeoutMs: Number(env?.MEMORY_MEM0_TIMEOUT_MS ?? 3000) || 3000, + }) + : null; + mem0Backend = createMem0MemoryBackend({ + enabled: mem0Enabled, + apiKey: mem0ApiKey, + projectId: mem0ProjectId || undefined, + client: mem0Client, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] mem0 backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + mem0Backend = createMem0MemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (mem0Backend) backends.push(mem0Backend); + let lettaBackend = null; + const lettaEnabled = readFlag(env, 'MEMORY_LETTA_ENABLED', false); + const lettaApiKey = String(env?.MEMORY_LETTA_API_KEY ?? '').trim(); + const lettaProjectId = String(env?.MEMORY_LETTA_PROJECT_ID ?? '').trim(); + const lettaAgentId = String(env?.MEMORY_LETTA_AGENT_ID ?? '').trim(); + const lettaBaseUrl = String(env?.MEMORY_LETTA_BASE_URL ?? 'https://api.letta.com').trim(); + const lettaModelProviderKeyId = String(env?.MEMORY_LETTA_MODEL_PROVIDER_KEY_ID ?? '').trim(); + const lettaModel = String(env?.MEMORY_LETTA_MODEL ?? '').trim(); + const lettaModelApiType = String(env?.MEMORY_LETTA_MODEL_API ?? '').trim(); + if (lettaEnabled || lettaApiKey || lettaProjectId || lettaAgentId) { + try { + const resolvedLettaClient = lettaClient ?? ( + lettaEnabled && lettaApiKey && lettaAgentId + ? createLettaHttpClient({ + apiKey: lettaApiKey, + baseUrl: lettaBaseUrl, + agentId: lettaAgentId, + projectId: lettaProjectId || undefined, + resolvePath: env?.MEMORY_LETTA_RESOLVE_PATH || undefined, + writePath: env?.MEMORY_LETTA_WRITE_PATH || undefined, + compactPath: env?.MEMORY_LETTA_COMPACT_PATH || undefined, + modelProviderKeyId: lettaModelProviderKeyId || undefined, + model: lettaModel || undefined, + modelApiType: lettaModelApiType || undefined, + fetchImpl, + timeoutMs: Number(env?.MEMORY_LETTA_TIMEOUT_MS ?? 3000) || 3000, + }) + : null + ); + lettaBackend = createLettaMemoryBackend({ + enabled: lettaEnabled, + apiKey: lettaApiKey, + projectId: lettaProjectId || undefined, + agentId: lettaAgentId || undefined, + client: resolvedLettaClient, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] letta backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + lettaBackend = createLettaMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (lettaBackend) backends.push(lettaBackend); + let neo4jBackend = null; + const neo4jEnabled = readFlag(env, 'MEMORY_NEO4J_ENABLED', false); + const neo4jUri = String(env?.MEMORY_NEO4J_URI ?? '').trim(); + const neo4jHttpUrl = String(env?.MEMORY_NEO4J_HTTP_URL ?? '').trim(); + const neo4jUsername = String(env?.MEMORY_NEO4J_USER ?? '').trim(); + const neo4jPassword = String(env?.MEMORY_NEO4J_PASSWORD ?? '').trim(); + if (neo4jEnabled || neo4jUri || neo4jHttpUrl || neo4jUsername || neo4jPassword) { + try { + const resolvedNeo4jClient = neo4jClient ?? ( + neo4jEnabled && neo4jHttpUrl && neo4jUsername && neo4jPassword + ? createNeo4jHttpClient({ + httpUrl: neo4jHttpUrl, + username: neo4jUsername, + password: neo4jPassword, + database: env?.MEMORY_NEO4J_DATABASE || 'neo4j', + fetchImpl, + timeoutMs: Number(env?.MEMORY_NEO4J_TIMEOUT_MS ?? 3000) || 3000, + }) + : null + ); + neo4jBackend = createNeo4jMemoryBackend({ + enabled: neo4jEnabled, + uri: neo4jUri, + httpUrl: neo4jHttpUrl, + username: neo4jUsername, + password: neo4jPassword, + client: resolvedNeo4jClient, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] neo4j backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + neo4jBackend = createNeo4jMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (neo4jBackend) backends.push(neo4jBackend); + let redisStreamsBackend = null; + const redisStreamsEnabled = readFlag(env, 'MEMORY_REDIS_STREAMS_ENABLED', false); + const redisStreamsUrl = String(env?.MEMORY_REDIS_STREAMS_URL ?? '').trim(); + const redisStreamsStream = String(env?.MEMORY_REDIS_STREAMS_STREAM ?? '').trim(); + if (redisStreamsEnabled || redisStreamsUrl || redisStreamsStream) { + try { + const resolvedRedisStreamsClient = redisStreamsClient ?? ( + redisStreamsEnabled && redisStreamsUrl + ? await createRedisStreamsClient({ + url: redisStreamsUrl, + stream: redisStreamsStream || undefined, + importRedis, + }) + : null + ); + if (resolvedRedisStreamsClient?.close) closers.push(() => resolvedRedisStreamsClient.close()); + redisStreamsBackend = createRedisStreamsMemoryBackend({ + enabled: redisStreamsEnabled, + url: redisStreamsUrl, + stream: redisStreamsStream || undefined, + client: resolvedRedisStreamsClient, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] redis-streams backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + redisStreamsBackend = createRedisStreamsMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (redisStreamsBackend) backends.push(redisStreamsBackend); + let langGraphBackend = null; + const langGraphEnabled = readFlag(env, 'MEMORY_LANGGRAPH_ENABLED', false); + const langGraphPolicyId = String(env?.MEMORY_LANGGRAPH_POLICY_ID ?? '').trim(); + const langGraphUrl = String(env?.MEMORY_LANGGRAPH_URL ?? '').trim(); + const langGraphModelProviderKeyId = String(env?.MEMORY_LANGGRAPH_MODEL_PROVIDER_KEY_ID ?? '').trim(); + const langGraphModel = String(env?.MEMORY_LANGGRAPH_MODEL ?? '').trim(); + const langGraphModelApiType = String(env?.MEMORY_LANGGRAPH_MODEL_API ?? '').trim(); + if (langGraphEnabled || langGraphPolicyId || langGraphUrl) { + try { + const resolvedLangGraphClient = langGraphClient ?? ( + langGraphEnabled && langGraphUrl + ? createLangGraphHttpClient({ + baseUrl: langGraphUrl, + apiKey: env?.MEMORY_LANGGRAPH_API_KEY, + resolvePath: env?.MEMORY_LANGGRAPH_RESOLVE_PATH || undefined, + modelProviderKeyId: langGraphModelProviderKeyId || undefined, + model: langGraphModel || undefined, + modelApiType: langGraphModelApiType || undefined, + fetchImpl, + timeoutMs: Number(env?.MEMORY_LANGGRAPH_TIMEOUT_MS ?? 3000) || 3000, + }) + : null + ); + langGraphBackend = createLangGraphMemoryBackend({ + enabled: langGraphEnabled, + policyId: langGraphPolicyId || undefined, + client: resolvedLangGraphClient, + }); + } catch (err) { + logger?.warn?.( + `[memory-v2] langgraph backend unavailable: ${err instanceof Error ? err.message : err}`, + ); + langGraphBackend = createLangGraphMemoryBackend({ + enabled: false, + unavailableReason: 'init_failed', + }); + } + } + if (langGraphBackend) backends.push(langGraphBackend); + backends.push(...createMemoryV2PluginBackends({ + exclude: [ + ...(pgvectorBackend ? ['pgvector'] : []), + ...(qdrantBackend ? ['qdrant'] : []), + ...(weaviateBackend ? ['weaviate'] : []), + ...(mem0Backend ? ['mem0'] : []), + ...(lettaBackend ? ['letta'] : []), + ...(neo4jBackend ? ['neo4j'] : []), + ...(redisStreamsBackend ? ['redis-streams'] : []), + ...(langGraphBackend ? ['langgraph'] : []), + ], + })); + + const memory = createMemoryV2({ + legacyMemoryService, + backends, + env, + logger, + }); + + memory.close = async () => { + const results = await Promise.allSettled(closers.map((close) => close())); + for (const result of results) { + if (result.status === 'rejected') { + logger?.warn?.( + `[memory-v2] close skipped: ${result.reason instanceof Error ? result.reason.message : result.reason}`, + ); + } + } + }; + + return memory; +} + +export async function createManagedMemoryV2Runtime({ + legacyMemoryService = null, + configService = null, + env = process.env, + logger = console, + importPg = (specifier) => import(specifier), + importRedis = (specifier) => import(specifier), + importModule = (specifier) => import(specifier), + fetchImpl = globalThis.fetch, + lettaClient = null, + weaviateClient = null, + neo4jClient = null, + redisStreamsClient = null, + langGraphClient = null, +} = {}) { + let activeRuntime = null; + let activeFingerprint = null; + let activeMeta = { source: 'env', updatedAt: null, updatedBy: null, configError: null }; + + async function loadRuntimeState() { + if (!configService?.getRuntimeState) { + return { + source: 'env', + updatedAt: null, + updatedBy: null, + fingerprint: 'env-only', + effectiveEnv: { ...env }, + configError: null, + }; + } + try { + const state = await configService.getRuntimeState(); + return { + source: state?.source ?? 'admin-db', + updatedAt: state?.updatedAt ?? null, + updatedBy: state?.updatedBy ?? null, + fingerprint: state?.fingerprint ?? `admin-db:${Date.now()}`, + effectiveEnv: { ...env, ...(state?.overrides ?? {}) }, + configError: null, + }; + } catch (err) { + logger?.warn?.( + `[memory-v2] admin config unavailable, using process env: ${err instanceof Error ? err.message : err}`, + ); + return { + source: 'env-fallback', + updatedAt: null, + updatedBy: null, + fingerprint: 'env-fallback', + effectiveEnv: { ...env }, + configError: err instanceof Error ? err.message : String(err), + }; + } + } + + async function ensureRuntime() { + const state = await loadRuntimeState(); + if (activeRuntime && activeFingerprint === state.fingerprint) { + activeMeta = state; + return activeRuntime; + } + const nextRuntime = await createMemoryV2Runtime({ + legacyMemoryService, + env: state.effectiveEnv, + logger, + importPg, + importRedis, + importModule, + fetchImpl, + lettaClient, + weaviateClient, + neo4jClient, + redisStreamsClient, + langGraphClient, + }); + const previous = activeRuntime; + activeRuntime = nextRuntime; + activeFingerprint = state.fingerprint; + activeMeta = state; + if (previous?.close) { + await previous.close().catch((err) => { + logger?.warn?.( + `[memory-v2] previous runtime close skipped: ${err instanceof Error ? err.message : err}`, + ); + }); + } + return activeRuntime; + } + + const managed = { + get policy() { + return activeRuntime?.policy ?? resolveMemoryV2Policy({ env }); + }, + + async getStatus() { + const runtime = await ensureRuntime(); + const status = await Promise.resolve(runtime.getStatus()); + return { + ...status, + configSource: activeMeta.source, + configUpdatedAt: activeMeta.updatedAt, + configUpdatedBy: activeMeta.updatedBy, + configError: activeMeta.configError, + }; + }, + + async resolve(input = {}) { + const runtime = await ensureRuntime(); + return runtime.resolve(input); + }, + + async write(input = {}) { + const runtime = await ensureRuntime(); + return runtime.write(input); + }, + + async compact(input = {}) { + const runtime = await ensureRuntime(); + return runtime.compact(input); + }, + + async close() { + const runtime = activeRuntime; + activeRuntime = null; + activeFingerprint = null; + if (runtime?.close) { + await runtime.close(); + } + }, + }; + + return managed; +} diff --git a/memory-v2-runtime.test.mjs b/memory-v2-runtime.test.mjs new file mode 100644 index 0000000..148f444 --- /dev/null +++ b/memory-v2-runtime.test.mjs @@ -0,0 +1,676 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createManagedMemoryV2Runtime, createMemoryV2Runtime } from './memory-v2-runtime.mjs'; + +function silentLogger() { + return { + warnings: [], + warn(message) { + this.warnings.push(message); + }, + }; +} + +function legacyService(memories = []) { + return { + async listMemories() { + return memories; + }, + async saveAndAnalyze() { + return { saved: 1, analyzed: 1, memories: 1 }; + }, + async analyzeUser() { + return { analyzed: 1, memories: 1 }; + }, + }; +} + +test('createMemoryV2Runtime keeps pgvector dormant without explicit pg URL', async () => { + let importedPg = false; + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy memory' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + }, + importPg() { + importedPg = true; + throw new Error('should not import pg'); + }, + }); + + const result = await memory.resolve({ userId: 'u1', query: 'hello' }); + const pgvector = memory.getStatus().backends.find((backend) => backend.name === 'pgvector'); + + assert.equal(importedPg, false); + assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory'); + assert.equal(pgvector.available, false); + assert.equal(pgvector.reason, 'not_configured'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy memory' }]); +}); + +test('createMemoryV2Runtime does not open pg pool when embedding module is missing', async () => { + let importedPg = false; + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService(), + env: { + MEMORY_ENABLED: '1', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory', + }, + importPg() { + importedPg = true; + throw new Error('should not import pg without embedding module'); + }, + }); + + const pgvector = memory.getStatus().backends.find((backend) => backend.name === 'pgvector'); + + assert.equal(importedPg, false); + assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory'); + assert.equal(pgvector.available, false); + assert.equal(pgvector.reason, 'embedding_module_not_configured'); +}); + +test('createMemoryV2Runtime selects pgvector only when pool and embedding are configured', async () => { + const queries = []; + let poolEnded = false; + class FakePool { + constructor(options) { + this.options = options; + } + + async query(sql, params) { + queries.push({ sql, params, options: this.options }); + return { + rows: [ + { + id: 1, + type: 'fact', + content: 'semantic memory', + score: 0.91, + created_at: '2026-07-02T00:00:00.000Z', + }, + ], + }; + } + + async end() { + poolEnded = true; + } + } + + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy memory' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs', + MEMORY_PGVECTOR_POOL_MAX: '2', + }, + async importPg(specifier) { + assert.equal(specifier, 'pg'); + return { Pool: FakePool }; + }, + async importModule(specifier) { + assert.match(specifier, /fake-embed\.mjs$/); + return { + async embedQuery(query) { + assert.equal(query, 'memory-chain'); + return [0.1, 0.2, 0.3]; + }, + }; + }, + async fetchImpl(url, options) { + assert.equal(url, 'http://127.0.0.1:6333/collections/memind_memory/points/search'); + assert.equal(options.method, 'POST'); + assert.deepEqual(JSON.parse(options.body), { + vector: [0.1, 0.2, 0.3], + limit: 4, + with_payload: true, + filter: { + must: [{ + key: 'user_id', + match: { value: 'u1' }, + }], + }, + }); + return { + ok: true, + async json() { + return { + result: [{ + id: 'q1', + score: 0.88, + payload: { + content: 'qdrant memory', + type: 'fact', + }, + }], + }; + }, + }; + }, + }); + + const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.equal(memory.getStatus().selectedBackend, 'pgvector'); + assert.equal(result.source, 'pgvector'); + assert.deepEqual(result.semanticMemories, ['semantic memory']); + assert.equal(queries.length, 1); + assert.equal(queries[0].options.connectionString, 'postgresql://local/memory'); + assert.equal(queries[0].options.max, 2); + assert.deepEqual(queries[0].params, ['u1', '[0.1,0.2,0.3]', 8]); + + await memory.close(); + assert.equal(poolEnded, true); +}); + +test('createMemoryV2Runtime falls back to legacy when pgvector init fails', async () => { + const logger = silentLogger(); + const memory = await createMemoryV2Runtime({ + logger, + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy fallback' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './bad-embed.mjs', + }, + async importModule() { + return {}; + }, + }); + + const result = await memory.resolve({ userId: 'u1', query: 'hello' }); + const pgvector = memory.getStatus().backends.find((backend) => backend.name === 'pgvector'); + + assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory'); + assert.equal(pgvector.available, false); + assert.equal(pgvector.reason, 'init_failed'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy fallback' }]); + assert.match(logger.warnings[0], /pgvector backend unavailable/); +}); + +test('createMemoryV2Runtime replaces qdrant placeholder without network wiring', async () => { + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy qdrant fallback' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'qdrant', + MEMORY_QDRANT_ENABLED: '1', + MEMORY_QDRANT_URL: 'http://127.0.0.1:6333', + MEMORY_QDRANT_COLLECTION: 'memind_memory', + }, + }); + + const status = memory.getStatus(); + const qdrant = status.backends.find((backend) => backend.name === 'qdrant'); + const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(qdrant.available, false); + assert.equal(qdrant.reason, 'client_not_configured'); + assert.equal(qdrant.category, 'semantic'); + assert.equal(qdrant.flag, 'MEMORY_QDRANT_ENABLED'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy qdrant fallback' }]); +}); + +test('createMemoryV2Runtime can select qdrant read-only client when embedding module is configured', async () => { + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy qdrant fallback' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'qdrant', + MEMORY_QDRANT_ENABLED: '1', + MEMORY_QDRANT_URL: 'http://127.0.0.1:6333', + MEMORY_QDRANT_COLLECTION: 'memind_memory', + MEMORY_QDRANT_EMBEDDING_MODULE: './qdrant-embed.mjs', + MEMORY_QDRANT_LIMIT: '4', + }, + async importModule(specifier) { + assert.match(specifier, /qdrant-embed\.mjs$/); + return { + async embedQuery(query) { + assert.equal(query, 'memory-chain'); + return [0.1, 0.2, 0.3]; + }, + }; + }, + async fetchImpl(url, options) { + assert.equal(url, 'http://127.0.0.1:6333/collections/memind_memory/points/search'); + assert.equal(options.method, 'POST'); + assert.deepEqual(JSON.parse(options.body), { + vector: [0.1, 0.2, 0.3], + limit: 4, + with_payload: true, + filter: { + must: [{ + key: 'user_id', + match: { value: 'u1' }, + }], + }, + }); + return { + ok: true, + async json() { + return { + result: [{ + id: 'q1', + score: 0.88, + payload: { + content: 'qdrant memory', + type: 'fact', + }, + }], + }; + }, + }; + }, + }); + + const qdrant = memory.getStatus().backends.find((backend) => backend.name === 'qdrant'); + const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.equal(memory.getStatus().selectedBackend, 'qdrant'); + assert.equal(qdrant.available, true); + assert.equal(result.source, 'qdrant'); + assert.deepEqual(result.memories, [{ + id: 'q1', + label: 'fact', + text: 'qdrant memory', + score: 0.88, + createdAt: null, + }]); +}); + +test('createManagedMemoryV2Runtime refreshes backend selection from admin config state', async () => { + let state = { + fingerprint: 'v1', + overrides: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'legacy', + }, + }; + const memory = await createManagedMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy managed' }]), + configService: { + async getRuntimeState() { + return state; + }, + }, + async importModule(specifier) { + assert.match(specifier, /managed-embed\.mjs$/); + return { + async embedQuery() { + return [0.1, 0.2, 0.3]; + }, + }; + }, + async importPg() { + return { + Pool: class FakePool { + async query() { + return { + rows: [{ + id: 1, + type: 'fact', + content: 'pgvector managed', + score: 0.9, + created_at: '2026-07-02T00:00:00.000Z', + }], + }; + } + + async end() {} + }, + }; + }, + }); + + const legacyResult = await memory.resolve({ userId: 'u1', query: 'hello' }); + assert.equal(legacyResult.source, 'legacy-conversation-memory'); + + state = { + fingerprint: 'v2', + overrides: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_PGVECTOR_ENABLED: '1', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './managed-embed.mjs', + }, + }; + + const pgvectorResult = await memory.resolve({ userId: 'u1', query: 'hello' }); + const status = await memory.getStatus(); + + assert.equal(pgvectorResult.source, 'pgvector'); + assert.equal(status.selectedBackend, 'pgvector'); + assert.equal(status.configSource, 'admin-db'); +}); + +test('createMemoryV2Runtime reports qdrant init failure without blocking memory', async () => { + const logger = silentLogger(); + const memory = await createMemoryV2Runtime({ + logger, + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy after qdrant error' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'qdrant', + MEMORY_QDRANT_ENABLED: '1', + MEMORY_QDRANT_URL: 'ftp://bad-qdrant', + }, + }); + + const status = memory.getStatus(); + const qdrant = status.backends.find((backend) => backend.name === 'qdrant'); + const result = await memory.resolve({ userId: 'u1' }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(qdrant.available, false); + assert.equal(qdrant.reason, 'init_failed'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy after qdrant error' }]); + assert.match(logger.warnings[0], /qdrant backend unavailable/); +}); + +test('createMemoryV2Runtime keeps mem0 as fallback when api key is incomplete', async () => { + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy resolve with mem0' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'mem0', + MEMORY_MEM0_ENABLED: '1', + MEMORY_MEM0_PROJECT_ID: 'memind_project', + }, + }); + + const status = memory.getStatus(); + const mem0 = status.backends.find((backend) => backend.name === 'mem0'); + const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] }); + const compacted = await memory.compact({ userId: 'u1' }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(mem0.available, false); + assert.equal(mem0.reason, 'api_key_not_configured'); + assert.equal(mem0.category, 'extraction'); + assert.equal(mem0.flag, 'MEMORY_MEM0_ENABLED'); + assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy resolve with mem0' }]); + assert.equal(written.source, 'legacy-conversation-memory'); + assert.equal(compacted.source, 'legacy-conversation-memory'); +}); + +test('createMemoryV2Runtime can write and compact through Mem0 HTTP client', async () => { + const requests = []; + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy resolve with mem0' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'mem0', + MEMORY_MEM0_ENABLED: '1', + MEMORY_MEM0_API_KEY: 'secret', + MEMORY_MEM0_PROJECT_ID: 'memind_project', + MEMORY_MEM0_BASE_URL: 'https://mem0.local', + }, + async fetchImpl(url, options) { + requests.push({ url, body: JSON.parse(options.body) }); + return { + ok: true, + async json() { + return { saved: 1, analyzed: 1, memories: 1 }; + }, + }; + }, + }); + + const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [{ text: 'hi' }] }); + const compacted = await memory.compact({ userId: 'u1' }); + + assert.equal(memory.getStatus().selectedBackend, 'legacy-conversation-memory'); + assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy resolve with mem0' }]); + assert.equal(written.source, 'mem0'); + assert.equal(compacted.source, 'mem0'); + assert.deepEqual(requests.map((request) => request.url), [ + 'https://mem0.local/v1/memories', + 'https://mem0.local/v1/memories/compact', + ]); +}); + +test('createMemoryV2Runtime reports mem0 init failure without blocking memory', async () => { + const logger = silentLogger(); + const memory = await createMemoryV2Runtime({ + logger, + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy after mem0 error' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'mem0', + MEMORY_MEM0_ENABLED: '1', + MEMORY_MEM0_API_KEY: 'secret', + MEMORY_MEM0_PROJECT_ID: 'bad project id', + }, + }); + + const status = memory.getStatus(); + const mem0 = status.backends.find((backend) => backend.name === 'mem0'); + const result = await memory.resolve({ userId: 'u1' }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(mem0.available, false); + assert.equal(mem0.reason, 'init_failed'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy after mem0 error' }]); + assert.match(logger.warnings[0], /mem0 backend unavailable/); +}); + +test('createMemoryV2Runtime keeps letta as fallback when agent id is incomplete', async () => { + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy resolve with letta' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'letta', + MEMORY_LETTA_ENABLED: '1', + MEMORY_LETTA_API_KEY: 'secret', + MEMORY_LETTA_PROJECT_ID: 'memind_project', + }, + }); + + const status = memory.getStatus(); + const letta = status.backends.find((backend) => backend.name === 'letta'); + const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] }); + const compacted = await memory.compact({ userId: 'u1' }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(letta.available, false); + assert.equal(letta.reason, 'client_not_configured'); + assert.equal(letta.category, 'lifecycle'); + assert.equal(letta.flag, 'MEMORY_LETTA_ENABLED'); + assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy resolve with letta' }]); + assert.equal(written.source, 'legacy-conversation-memory'); + assert.equal(compacted.source, 'legacy-conversation-memory'); +}); + +test('createMemoryV2Runtime can select letta with default HTTP client', async () => { + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy letta fallback' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'letta', + MEMORY_LETTA_ENABLED: '1', + MEMORY_LETTA_API_KEY: 'secret', + MEMORY_LETTA_PROJECT_ID: 'memind_project', + MEMORY_LETTA_AGENT_ID: 'agent_1', + MEMORY_LETTA_BASE_URL: 'https://letta.local', + }, + async fetchImpl(url, options) { + assert.match(url, /^https:\/\/letta\.local\/v1\/agents\/agent_1\//); + assert.equal(options.headers.authorization, 'Bearer secret'); + if (url.endsWith('/memory')) { + return { + ok: true, + async json() { + return { memories: ['letta lifecycle memory'], activeGoals: ['memory-v2'] }; + }, + }; + } + return { + ok: true, + async json() { + return { saved: 1, analyzed: 1, memories: 1 }; + }, + }; + }, + }); + + const status = memory.getStatus(); + const letta = status.backends.find((backend) => backend.name === 'letta'); + const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] }); + const compacted = await memory.compact({ userId: 'u1' }); + + assert.equal(status.selectedBackend, 'letta'); + assert.equal(letta.available, true); + assert.equal(resolved.source, 'letta'); + assert.deepEqual(resolved.memories, [{ label: 'lifecycle', text: 'letta lifecycle memory' }]); + assert.deepEqual(resolved.activeGoals, ['memory-v2']); + assert.equal(written.source, 'letta'); + assert.equal(compacted.source, 'letta'); +}); + +test('createMemoryV2Runtime reports letta init failure without blocking memory', async () => { + const logger = silentLogger(); + const memory = await createMemoryV2Runtime({ + logger, + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy after letta error' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'letta', + MEMORY_LETTA_ENABLED: '1', + MEMORY_LETTA_API_KEY: 'secret', + MEMORY_LETTA_AGENT_ID: 'bad agent id', + }, + }); + + const status = memory.getStatus(); + const letta = status.backends.find((backend) => backend.name === 'letta'); + const result = await memory.resolve({ userId: 'u1' }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(letta.available, false); + assert.equal(letta.reason, 'init_failed'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy after letta error' }]); + assert.match(logger.warnings[0], /letta backend unavailable/); +}); + +test('createMemoryV2Runtime wires remaining external adapters as safe fallbacks', async () => { + const memory = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy external fallback' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'weaviate', + MEMORY_WEAVIATE_ENABLED: '1', + MEMORY_WEAVIATE_URL: 'https://weaviate.local', + MEMORY_NEO4J_ENABLED: '1', + MEMORY_NEO4J_URI: 'neo4j://localhost:7687', + MEMORY_NEO4J_USER: 'neo4j', + MEMORY_NEO4J_PASSWORD: 'secret', + MEMORY_REDIS_STREAMS_ENABLED: '1', + MEMORY_LANGGRAPH_ENABLED: '1', + MEMORY_LANGGRAPH_POLICY_ID: 'memory_policy', + }, + }); + + const status = memory.getStatus(); + const byName = new Map(status.backends.map((backend) => [backend.name, backend])); + const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] }); + + assert.equal(status.selectedBackend, 'legacy-conversation-memory'); + assert.equal(byName.get('weaviate').reason, 'client_not_configured'); + assert.equal(byName.get('neo4j').reason, 'client_not_configured'); + assert.equal(byName.get('redis-streams').reason, 'url_not_configured'); + assert.equal(byName.get('langgraph').reason, 'client_not_configured'); + assert.deepEqual(resolved.memories, [{ label: 'fact', text: 'legacy external fallback' }]); + assert.equal(written.source, 'legacy-conversation-memory'); +}); + +test('createMemoryV2Runtime can select external adapters with built-in HTTP clients', async () => { + const weaviate = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService([{ label: 'fact', text: 'legacy weaviate fallback' }]), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'weaviate', + MEMORY_WEAVIATE_ENABLED: '1', + MEMORY_WEAVIATE_URL: 'https://weaviate.local', + MEMORY_WEAVIATE_EMBEDDING_MODULE: './weaviate-embed.mjs', + }, + async importModule() { + return { async embedQuery() { return [0.1, 0.2]; } }; + }, + async fetchImpl(url, options) { + assert.equal(url, 'https://weaviate.local/v1/graphql'); + assert.equal(options.method, 'POST'); + return { + ok: true, + async json() { + return { + data: { + Get: { + MemindMemory: [{ + content: 'weaviate selected memory', + _additional: { id: 'w1', certainty: 0.9 }, + }], + }, + }, + }; + }, + }; + }, + }); + assert.equal(weaviate.getStatus().selectedBackend, 'weaviate'); + assert.equal((await weaviate.resolve({ userId: 'u1', query: 'memory-chain' })).source, 'weaviate'); + + const langGraph = await createMemoryV2Runtime({ + logger: silentLogger(), + legacyMemoryService: legacyService(), + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'langgraph', + MEMORY_LANGGRAPH_ENABLED: '1', + MEMORY_LANGGRAPH_POLICY_ID: 'memory_policy', + MEMORY_LANGGRAPH_URL: 'https://langgraph.local', + }, + async fetchImpl(url, options) { + assert.equal(url, 'https://langgraph.local/memory/resolve'); + assert.equal(options.method, 'POST'); + return { + ok: true, + async json() { + return { activeGoals: ['route-memory'] }; + }, + }; + }, + }); + assert.equal(langGraph.getStatus().selectedBackend, 'langgraph'); + assert.deepEqual((await langGraph.resolve({ userId: 'u1' })).activeGoals, ['route-memory']); +}); diff --git a/memory-v2-weaviate.mjs b/memory-v2-weaviate.mjs new file mode 100644 index 0000000..8ac9dd5 --- /dev/null +++ b/memory-v2-weaviate.mjs @@ -0,0 +1,200 @@ +function normalizeUrl(value, envName = 'MEMORY_WEAVIATE_URL') { + const raw = String(value ?? '').trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Unsupported protocol: ${url.protocol}`); + } + return url.toString().replace(/\/$/, ''); + } catch (err) { + throw new Error(`Invalid ${envName}: ${err instanceof Error ? err.message : err}`); + } +} + +function normalizeName(value, fallback, label) { + const raw = String(value ?? fallback).trim(); + if (!/^[a-zA-Z0-9_-]+$/.test(raw)) throw new Error(`Invalid ${label}: ${raw}`); + return raw; +} + +function normalizeMemory(item) { + const text = String(item?.text ?? item?.content ?? item?.memoryText ?? '').trim(); + if (!text) return null; + return { + id: item?.id == null ? null : String(item.id), + label: item?.label ?? item?.type ?? 'semantic', + text, + score: item?.score == null ? null : Number(item.score), + createdAt: item?.createdAt ?? item?.created_at ?? null, + }; +} + +function normalizeVector(value) { + if (!Array.isArray(value)) return null; + const vector = value.map((item) => Number(item)); + return vector.length && vector.every(Number.isFinite) ? vector : null; +} + +function timeoutSignal(timeoutMs) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + clear() { + clearTimeout(timeout); + }, + }; +} + +export function createWeaviateHttpClient({ + url, + apiKey = null, + fetchImpl = globalThis.fetch, + timeoutMs = 3000, +} = {}) { + const resolvedUrl = normalizeUrl(url); + if (!resolvedUrl) throw new Error('createWeaviateHttpClient requires MEMORY_WEAVIATE_URL'); + if (typeof fetchImpl !== 'function') throw new Error('createWeaviateHttpClient requires fetch'); + + return { + async search({ collection, vector, userId, limit = 8 } = {}) { + const resolvedCollection = normalizeName(collection, 'MemindMemory', 'MEMORY_WEAVIATE_COLLECTION'); + const resolvedVector = normalizeVector(vector); + if (!resolvedVector) return []; + const resolvedLimit = Math.max(1, Math.min(50, Number(limit) || 8)); + const whereType = `GetObjects${resolvedCollection}WhereInpObj`; + const query = ` + query MemoryV2Search($vector: [Float!]!, $where: ${whereType}) { + Get { + ${resolvedCollection}(nearVector: { vector: $vector }, limit: ${resolvedLimit}, where: $where) { + content + text + type + label + created_at + _additional { + id + certainty + distance + } + } + } + } + `; + const variables = { + vector: resolvedVector, + where: userId + ? { + path: ['user_id'], + operator: 'Equal', + valueText: String(userId), + } + : null, + }; + const timeout = timeoutSignal(Number(timeoutMs)); + try { + const response = await fetchImpl(`${resolvedUrl}/v1/graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ query, variables }), + ...(timeout?.signal ? { signal: timeout.signal } : {}), + }); + if (!response?.ok) { + throw new Error(`Weaviate request failed: ${response?.status ?? 'unknown'}`); + } + const data = await response.json(); + if (Array.isArray(data?.errors) && data.errors.length) { + throw new Error(`Weaviate GraphQL error: ${data.errors[0]?.message ?? 'unknown'}`); + } + return Array.isArray(data?.data?.Get?.[resolvedCollection]) + ? data.data.Get[resolvedCollection].map((item) => ({ + id: item?._additional?.id, + label: item?.label ?? item?.type, + text: item?.text ?? item?.content, + score: item?._additional?.certainty ?? ( + item?._additional?.distance == null ? null : 1 - Number(item._additional.distance) + ), + createdAt: item?.createdAt ?? item?.created_at, + })) + : []; + } finally { + timeout?.clear(); + } + }, + }; +} + +export function createWeaviateMemoryBackend({ + enabled = false, + url = null, + collection = 'MemindMemory', + client = null, + embedQuery = null, + defaultLimit = 8, + unavailableReason = null, +} = {}) { + const resolvedUrl = normalizeUrl(url); + const resolvedCollection = normalizeName(collection, 'MemindMemory', 'MEMORY_WEAVIATE_COLLECTION'); + const configured = Boolean(enabled && resolvedUrl); + const hasClient = Boolean(client?.search); + const hasEmbedding = typeof embedQuery === 'function'; + const wired = Boolean(configured && hasClient && hasEmbedding); + const reason = unavailableReason + ?? (enabled + ? (!resolvedUrl ? 'url_not_configured' : (!hasClient ? 'client_not_configured' : 'embedding_module_not_configured')) + : 'not_configured'); + + async function resolveVector(input) { + const explicit = normalizeVector(input?.embedding); + if (explicit) return explicit; + if (typeof embedQuery !== 'function' || !input?.query) return null; + return normalizeVector(await embedQuery(input.query, input)); + } + + return { + name: 'weaviate', + category: 'semantic', + role: 'knowledge-graph-fusion', + flag: 'MEMORY_WEAVIATE_ENABLED', + unavailableReason: reason, + url: resolvedUrl, + collection: resolvedCollection, + + isAvailable() { + return Boolean(wired); + }, + + getUnavailableReason() { + return this.isAvailable() ? null : reason; + }, + + async resolve(input = {}) { + if (!this.isAvailable()) { + return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [] }; + } + const vector = await resolveVector(input); + if (!vector) { + return { memories: [], semanticMemories: [], behaviorSummary: null, activeGoals: [] }; + } + const rows = await client.search({ + url: resolvedUrl, + collection: resolvedCollection, + vector, + userId: input.userId, + limit: input.limit ?? defaultLimit, + }); + const memories = (Array.isArray(rows) ? rows : []).map(normalizeMemory).filter(Boolean); + return { + memories, + semanticMemories: memories.map((memory) => memory.text), + behaviorSummary: null, + activeGoals: [], + }; + }, + }; +} diff --git a/memory-v2-weaviate.test.mjs b/memory-v2-weaviate.test.mjs new file mode 100644 index 0000000..980c2fe --- /dev/null +++ b/memory-v2-weaviate.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createWeaviateHttpClient } from './memory-v2-weaviate.mjs'; + +test('weaviate client uses WhereInput for GraphQL filter variables', async () => { + let requestBody = null; + const client = createWeaviateHttpClient({ + url: 'http://127.0.0.1:18080', + fetchImpl: async (_url, init) => { + requestBody = JSON.parse(String(init.body)); + return { + ok: true, + async json() { + return { + data: { + Get: { + MemoryItem: [{ + text: 'memory-chain smoke hit', + label: 'semantic', + _additional: { + id: '1', + certainty: 1, + }, + }], + }, + }, + }; + }, + }; + }, + }); + + const result = await client.search({ + collection: 'MemoryItem', + vector: [1, 0, 0], + userId: 'memory-v2-smoke-user', + limit: 1, + }); + + assert.match(requestBody.query, /\$where: GetObjectsMemoryItemWhereInpObj/); + assert.deepEqual(requestBody.variables.where, { + path: ['user_id'], + operator: 'Equal', + valueText: 'memory-v2-smoke-user', + }); + assert.equal(result.length, 1); + assert.equal(result[0].text, 'memory-chain smoke hit'); +}); diff --git a/memory-v2.mjs b/memory-v2.mjs new file mode 100644 index 0000000..afc8b7c --- /dev/null +++ b/memory-v2.mjs @@ -0,0 +1,359 @@ +import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs'; + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']); +const MAX_MEMORY_TEXT_LENGTH = 2000; +const MAX_CONTEXT_ITEMS = 50; + +function normalizeText(value, maxLength = MAX_MEMORY_TEXT_LENGTH) { + const text = String(value ?? '').trim(); + if (!text) return null; + return text.length > maxLength ? text.slice(0, maxLength) : text; +} + +function readFlag(env, key, fallback) { + const raw = env?.[key]; + if (raw == null || raw === '') return fallback; + const normalized = String(raw).trim().toLowerCase(); + if (TRUE_VALUES.has(normalized)) return true; + if (FALSE_VALUES.has(normalized)) return false; + return fallback; +} + +function emptyResolveResult({ + enabled, + skipped = false, + reason = null, + degraded = false, + source = null, +} = {}) { + return { + ok: !degraded, + enabled: Boolean(enabled), + skipped, + reason, + degraded, + source, + profile: null, + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + contextGoals: [], + memories: [], + }; +} + +function skippedWriteResult(reason) { + return { + ok: true, + enabled: false, + skipped: true, + reason, + source: null, + saved: 0, + analyzed: 0, + memories: 0, + }; +} + +export function resolveMemoryV2Policy({ env = process.env, overrides = {} } = {}) { + const legacyDefault = readFlag(env, 'USER_CONVERSATION_MEMORY_ENABLED', true); + const enabled = readFlag(env, 'MEMORY_ENABLED', legacyDefault); + return { + enabled, + profileEnabled: readFlag(env, 'MEMORY_PROFILE_ENABLED', enabled), + eventLogEnabled: readFlag(env, 'MEMORY_EVENT_LOG_ENABLED', enabled), + vectorEnabled: readFlag(env, 'MEMORY_VECTOR_ENABLED', false), + backend: String(env?.MEMORY_BACKEND ?? 'legacy').trim() || 'legacy', + failOpen: readFlag(env, 'MEMORY_FAIL_OPEN', true), + ...overrides, + }; +} + +export function createLegacyMemoryBackend(conversationMemoryService) { + return { + name: 'legacy-conversation-memory', + + isAvailable() { + return Boolean(conversationMemoryService); + }, + + async resolve({ userId, limit = 40 } = {}) { + if (!conversationMemoryService?.listMemories || !userId) { + return { memories: [] }; + } + const memories = await conversationMemoryService.listMemories(userId, { limit }); + return { memories }; + }, + + async write({ userId, sessionId, messages = [] } = {}) { + if (!conversationMemoryService?.saveAndAnalyze || !userId || !sessionId) { + return { saved: 0, analyzed: 0, memories: 0 }; + } + return conversationMemoryService.saveAndAnalyze(sessionId, userId, messages); + }, + + async compact({ userId } = {}) { + if (!conversationMemoryService?.analyzeUser || !userId) { + return { analyzed: 0, memories: 0 }; + } + return conversationMemoryService.analyzeUser(userId); + }, + }; +} + +function normalizeMemoryItem(item) { + if (typeof item === 'string') { + const text = normalizeText(item); + return text ? { label: 'memory', text } : null; + } + if (!item || typeof item !== 'object') return null; + const text = normalizeText( + item.text ?? item.content ?? item.memoryText ?? item.memory_text ?? '', + ); + if (!text) return null; + const normalized = { + label: normalizeText(item.label ?? item.type ?? 'memory', 80) ?? 'memory', + text, + }; + if (item.id != null) normalized.id = String(item.id); + const hasCreatedAt = Object.prototype.hasOwnProperty.call(item, 'createdAt') + || Object.prototype.hasOwnProperty.call(item, 'created_at'); + if (hasCreatedAt) normalized.createdAt = item.createdAt ?? item.created_at ?? null; + const score = item.score ?? item.confidence; + if (score != null && Number.isFinite(Number(score))) normalized.score = Number(score); + return normalized; +} + +function normalizeStringList(values) { + if (!Array.isArray(values)) return []; + return values + .map((value) => ( + typeof value === 'string' + ? normalizeText(value) + : normalizeText(value?.text ?? value?.content ?? value?.summary ?? '') + )) + .filter(Boolean) + .slice(0, MAX_CONTEXT_ITEMS); +} + +function normalizeMemoryList(values) { + if (!Array.isArray(values)) return []; + return values + .map((item) => normalizeMemoryItem(item)) + .filter(Boolean) + .slice(0, MAX_CONTEXT_ITEMS); +} + +function normalizeResolvePayload(payload, source, policy) { + const memories = normalizeMemoryList(payload?.memories); + const semanticMemories = Array.isArray(payload?.semanticMemories) + ? normalizeStringList(payload.semanticMemories) + : memories; + const contextGoals = normalizeStringList( + Array.isArray(payload?.contextGoals) + ? payload.contextGoals + : (Array.isArray(payload?.userLongTermGoals) ? payload.userLongTermGoals : payload?.activeGoals), + ); + return { + ok: true, + enabled: true, + skipped: false, + reason: null, + degraded: false, + source, + profile: policy.profileEnabled ? (payload?.profile ?? null) : null, + semanticMemories, + behaviorSummary: normalizeText(payload?.behaviorSummary, 4000), + activeGoals: contextGoals, + contextGoals, + memories, + }; +} + +function supportsOperation(backend, operation) { + if (!operation) return true; + return typeof backend?.[operation] === 'function'; +} + +function isBackendAvailable(backend) { + try { + return backend?.isAvailable?.() !== false; + } catch { + return false; + } +} + +function selectBackend(backends, policy, operation = null) { + if (!Array.isArray(backends) || backends.length === 0) return null; + const preferred = policy.backend; + if (preferred && preferred !== 'legacy') { + const exact = backends.find((backend) => backend?.name === preferred); + if (exact && isBackendAvailable(exact) && supportsOperation(exact, operation)) { + return exact; + } + } + return backends.find((backend) => ( + isBackendAvailable(backend) && supportsOperation(backend, operation) + )) ?? null; +} + +function backendStatus(backend) { + const name = String(backend?.name ?? 'unknown'); + let available = true; + try { + available = backend?.isAvailable?.() !== false; + } catch { + available = false; + } + const status = { + name, + available, + supports: { + resolve: typeof backend?.resolve === 'function', + write: typeof backend?.write === 'function', + compact: typeof backend?.compact === 'function', + }, + }; + if (backend?.category) status.category = String(backend.category); + if (backend?.role) status.role = String(backend.role); + if (backend?.flag) status.flag = String(backend.flag); + const reason = typeof backend?.getUnavailableReason === 'function' + ? backend.getUnavailableReason() + : backend?.unavailableReason; + if (!available && reason) status.reason = String(reason); + return status; +} + +export function createMemoryV2({ + legacyMemoryService = null, + backends = null, + policy = null, + env = process.env, + logger = console, +} = {}) { + const resolvedPolicy = policy ?? resolveMemoryV2Policy({ env }); + const resolvedBackends = backends ?? [ + createLegacyMemoryBackend(legacyMemoryService), + ...createMemoryV2PluginBackends(), + ]; + + async function failOpen(operation, err, fallback) { + logger?.warn?.( + `[memory-v2] ${operation} degraded: ${err instanceof Error ? err.message : err}`, + ); + if (resolvedPolicy.failOpen) return fallback; + throw err; + } + + async function resolve(input = {}) { + if (!resolvedPolicy.enabled) { + return emptyResolveResult({ enabled: false, skipped: true, reason: 'disabled' }); + } + const backend = selectBackend(resolvedBackends, resolvedPolicy, 'resolve'); + if (!backend?.resolve) { + return emptyResolveResult({ enabled: true, skipped: true, reason: 'no_backend' }); + } + try { + const payload = await backend.resolve(input); + return normalizeResolvePayload(payload, backend.name ?? 'unknown', resolvedPolicy); + } catch (err) { + return failOpen( + 'resolve', + err, + emptyResolveResult({ + enabled: true, + degraded: true, + reason: 'backend_failure', + source: backend.name ?? 'unknown', + }), + ); + } + } + + async function write(input = {}) { + if (!resolvedPolicy.enabled) return skippedWriteResult('disabled'); + if (!resolvedPolicy.eventLogEnabled) return skippedWriteResult('event_log_disabled'); + const backend = selectBackend(resolvedBackends, resolvedPolicy, 'write'); + if (!backend?.write) return skippedWriteResult('no_backend'); + try { + const result = await backend.write(input); + return { + ok: true, + enabled: true, + skipped: false, + reason: null, + source: backend.name ?? 'unknown', + saved: Number(result?.saved ?? 0), + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + } catch (err) { + return failOpen('write', err, { + ok: false, + enabled: true, + skipped: true, + reason: 'backend_failure', + source: backend.name ?? 'unknown', + saved: 0, + analyzed: 0, + memories: 0, + }); + } + } + + async function compact(input = {}) { + if (!resolvedPolicy.enabled) { + return { ok: true, enabled: false, skipped: true, reason: 'disabled' }; + } + const backend = selectBackend(resolvedBackends, resolvedPolicy, 'compact'); + if (!backend?.compact) { + return { ok: true, enabled: true, skipped: true, reason: 'no_backend' }; + } + try { + const result = await backend.compact(input); + return { + ok: true, + enabled: true, + skipped: false, + reason: null, + source: backend.name ?? 'unknown', + analyzed: Number(result?.analyzed ?? 0), + memories: Number(result?.memories ?? 0), + }; + } catch (err) { + return failOpen('compact', err, { + ok: false, + enabled: true, + skipped: true, + reason: 'backend_failure', + source: backend.name ?? 'unknown', + analyzed: 0, + memories: 0, + }); + } + } + + function getStatus() { + const backends = resolvedBackends.map((backend) => backendStatus(backend)); + const selected = selectBackend(resolvedBackends, resolvedPolicy, 'resolve'); + return { + enabled: Boolean(resolvedPolicy.enabled), + backend: resolvedPolicy.backend, + selectedBackend: selected?.name ?? null, + profileEnabled: Boolean(resolvedPolicy.profileEnabled), + eventLogEnabled: Boolean(resolvedPolicy.eventLogEnabled), + vectorEnabled: Boolean(resolvedPolicy.vectorEnabled), + failOpen: Boolean(resolvedPolicy.failOpen), + backends, + }; + } + + return { + policy: resolvedPolicy, + getStatus, + resolve, + write, + compact, + }; +} diff --git a/memory-v2.test.mjs b/memory-v2.test.mjs new file mode 100644 index 0000000..83917dd --- /dev/null +++ b/memory-v2.test.mjs @@ -0,0 +1,470 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createLegacyMemoryBackend, + createMemoryV2, + resolveMemoryV2Policy, +} from './memory-v2.mjs'; + +function silentLogger() { + return { warn() {} }; +} + +test('resolveMemoryV2Policy uses legacy memory flag for backward compatibility', () => { + assert.equal(resolveMemoryV2Policy({ env: {} }).enabled, true); + assert.equal( + resolveMemoryV2Policy({ + env: { USER_CONVERSATION_MEMORY_ENABLED: '0' }, + }).enabled, + false, + ); + assert.equal( + resolveMemoryV2Policy({ + env: { + USER_CONVERSATION_MEMORY_ENABLED: '0', + MEMORY_ENABLED: 'true', + }, + }).enabled, + true, + ); + assert.equal( + resolveMemoryV2Policy({ + env: { MEMORY_VECTOR_ENABLED: '1' }, + }).vectorEnabled, + true, + ); +}); + +test('legacy backend adapts existing conversation memory service', async () => { + const calls = []; + const backend = createLegacyMemoryBackend({ + async listMemories(userId, options) { + calls.push(['list', userId, options]); + return [{ id: 'm1', label: 'preference', text: '用户喜欢简洁回答' }]; + }, + async saveAndAnalyze(sessionId, userId, messages) { + calls.push(['write', sessionId, userId, messages.length]); + return { saved: messages.length, analyzed: 1, memories: 1 }; + }, + async analyzeUser(userId) { + calls.push(['compact', userId]); + return { analyzed: 2, memories: 1 }; + }, + }); + + const resolved = await backend.resolve({ userId: 'u1', limit: 3 }); + const written = await backend.write({ + userId: 'u1', + sessionId: 's1', + messages: [{ role: 'user', content: [] }], + }); + const compacted = await backend.compact({ userId: 'u1' }); + + assert.deepEqual(resolved.memories, [ + { id: 'm1', label: 'preference', text: '用户喜欢简洁回答' }, + ]); + assert.deepEqual(written, { saved: 1, analyzed: 1, memories: 1 }); + assert.deepEqual(compacted, { analyzed: 2, memories: 1 }); + assert.deepEqual(calls, [ + ['list', 'u1', { limit: 3 }], + ['write', 's1', 'u1', 1], + ['compact', 'u1'], + ]); +}); + +test('Memory V2 resolve returns unified payload from legacy backend', async () => { + const memory = createMemoryV2({ + logger: silentLogger(), + legacyMemoryService: { + async listMemories() { + return [{ id: 'm1', label: 'fact', text: '用户常做基础设施排障' }]; + }, + }, + env: { MEMORY_ENABLED: '1' }, + }); + + const result = await memory.resolve({ userId: 'u1', query: '帮我看 103' }); + + assert.equal(result.ok, true); + assert.equal(result.enabled, true); + assert.equal(result.source, 'legacy-conversation-memory'); + assert.deepEqual(result.memories, [ + { id: 'm1', label: 'fact', text: '用户常做基础设施排障' }, + ]); + assert.deepEqual(result.semanticMemories, result.memories); +}); + +test('Memory V2 feature flags can disable reads and writes without touching legacy service', async () => { + let touched = false; + const memory = createMemoryV2({ + logger: silentLogger(), + legacyMemoryService: { + async listMemories() { + touched = true; + return []; + }, + async saveAndAnalyze() { + touched = true; + return { saved: 1, analyzed: 1, memories: 1 }; + }, + }, + env: { MEMORY_ENABLED: '0' }, + }); + + assert.deepEqual(await memory.resolve({ userId: 'u1' }), { + ok: true, + enabled: false, + skipped: true, + reason: 'disabled', + degraded: false, + source: null, + profile: null, + semanticMemories: [], + behaviorSummary: null, + activeGoals: [], + contextGoals: [], + memories: [], + }); + assert.deepEqual(await memory.write({ userId: 'u1', sessionId: 's1' }), { + ok: true, + enabled: false, + skipped: true, + reason: 'disabled', + source: null, + saved: 0, + analyzed: 0, + memories: 0, + }); + assert.equal(touched, false); +}); + +test('Memory V2 fails open when backend resolve fails', async () => { + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'broken-backend', + async resolve() { + throw new Error('backend unavailable'); + }, + }, + ], + env: { MEMORY_ENABLED: '1' }, + }); + + const result = await memory.resolve({ userId: 'u1' }); + + assert.equal(result.ok, false); + assert.equal(result.enabled, true); + assert.equal(result.degraded, true); + assert.equal(result.reason, 'backend_failure'); + assert.deepEqual(result.memories, []); +}); + +test('Memory V2 write honors event log flag', async () => { + let touched = false; + const memory = createMemoryV2({ + logger: silentLogger(), + legacyMemoryService: { + async saveAndAnalyze() { + touched = true; + return { saved: 1, analyzed: 1, memories: 1 }; + }, + }, + env: { + MEMORY_ENABLED: '1', + MEMORY_EVENT_LOG_ENABLED: '0', + }, + }); + + const result = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] }); + + assert.equal(result.skipped, true); + assert.equal(result.reason, 'event_log_disabled'); + assert.equal(touched, false); +}); + +test('Memory V2 selects configured pluggable backend when available', async () => { + const calls = []; + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'legacy-conversation-memory', + async resolve() { + calls.push('legacy'); + return { memories: [{ text: 'legacy' }] }; + }, + }, + { + name: 'pgvector', + async resolve() { + calls.push('pgvector'); + return { + profile: { responseStyle: 'concise' }, + semanticMemories: ['用户关注 memory facade'], + memories: [{ label: 'fact', text: 'vector result' }], + }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_PROFILE_ENABLED: '1', + }, + }); + + const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.deepEqual(calls, ['pgvector']); + assert.equal(result.source, 'pgvector'); + assert.deepEqual(result.profile, { responseStyle: 'concise' }); + assert.deepEqual(result.semanticMemories, ['用户关注 memory facade']); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'vector result' }]); +}); + +test('Memory V2 normalizes external resolve payloads into tool-safe context fields', async () => { + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'langgraph', + async resolve() { + return { + tool: 'browser', + action: 'download', + url: 'https://example.invalid', + memories: [ + { + id: 'm1', + label: 'fact', + text: 'user prefers cautious memory rollouts', + tool: 'download', + functionCall: { name: 'bad' }, + url: 'https://example.invalid', + filePath: '/tmp/bad', + arguments: { value: 1 }, + score: '0.7', + created_at: '2026-07-03T00:00:00Z', + }, + ], + semanticMemories: [ + { text: 'semantic safe', toolName: 'bad_tool' }, + { action: 'call_tool', arguments: {} }, + 'plain semantic', + ], + behaviorSummary: 'Use memory as context only.', + activeGoals: [ + { text: 'preserve execution boundary', tool: 'bad' }, + { command: 'rm -rf /', arguments: [] }, + 'keep memory in context domain', + ], + }; + }, + }, + ], + env: { MEMORY_ENABLED: '1', MEMORY_BACKEND: 'langgraph' }, + }); + + const result = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + + assert.equal(result.source, 'langgraph'); + assert.deepEqual(result.memories, [ + { + id: 'm1', + label: 'fact', + text: 'user prefers cautious memory rollouts', + createdAt: '2026-07-03T00:00:00Z', + score: 0.7, + }, + ]); + assert.deepEqual(result.semanticMemories, ['semantic safe', 'plain semantic']); + assert.equal(result.behaviorSummary, 'Use memory as context only.'); + assert.deepEqual(result.contextGoals, [ + 'preserve execution boundary', + 'keep memory in context domain', + ]); + assert.deepEqual(result.activeGoals, result.contextGoals); + assert.equal(result.tool, undefined); + assert.equal(result.action, undefined); + assert.equal(result.url, undefined); + assert.equal(result.memories[0].tool, undefined); + assert.equal(result.memories[0].functionCall, undefined); + assert.equal(result.memories[0].url, undefined); + assert.equal(result.memories[0].filePath, undefined); + assert.equal(result.memories[0].arguments, undefined); +}); + +test('Memory V2 falls back to first available backend when preferred backend is unavailable', async () => { + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'pgvector', + isAvailable() { + return false; + }, + async resolve() { + throw new Error('should not use unavailable backend'); + }, + }, + { + name: 'legacy-conversation-memory', + async resolve() { + return { memories: [{ label: 'fact', text: 'legacy fallback' }] }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + }, + }); + + const result = await memory.resolve({ userId: 'u1' }); + + assert.equal(result.source, 'legacy-conversation-memory'); + assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy fallback' }]); +}); + +test('Memory V2 selects backends per operation so semantic read does not block legacy writes', async () => { + const calls = []; + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'pgvector', + isAvailable() { + return true; + }, + async resolve() { + calls.push('pgvector.resolve'); + return { memories: [{ label: 'semantic', text: 'vector read' }] }; + }, + }, + { + name: 'legacy-conversation-memory', + async resolve() { + calls.push('legacy.resolve'); + return { memories: [{ label: 'fact', text: 'legacy read' }] }; + }, + async write() { + calls.push('legacy.write'); + return { saved: 1, analyzed: 1, memories: 1 }; + }, + async compact() { + calls.push('legacy.compact'); + return { analyzed: 2, memories: 1 }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_EVENT_LOG_ENABLED: '1', + }, + }); + + const resolved = await memory.resolve({ userId: 'u1', query: 'memory-chain' }); + const written = await memory.write({ userId: 'u1', sessionId: 's1', messages: [] }); + const compacted = await memory.compact({ userId: 'u1' }); + + assert.equal(resolved.source, 'pgvector'); + assert.equal(written.source, 'legacy-conversation-memory'); + assert.equal(compacted.source, 'legacy-conversation-memory'); + assert.deepEqual(calls, ['pgvector.resolve', 'legacy.write', 'legacy.compact']); +}); + +test('Memory V2 can suppress profile payloads through policy', async () => { + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'profile-backend', + async resolve() { + return { + profile: { responseStyle: 'detailed' }, + memories: [{ label: 'preference', text: '用户喜欢完整回答' }], + }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_PROFILE_ENABLED: '0', + }, + }); + + const result = await memory.resolve({ userId: 'u1' }); + + assert.equal(result.profile, null); + assert.deepEqual(result.memories, [{ label: 'preference', text: '用户喜欢完整回答' }]); +}); + +test('Memory V2 getStatus exposes policy and backend contract details', () => { + const memory = createMemoryV2({ + logger: silentLogger(), + backends: [ + { + name: 'legacy-conversation-memory', + async resolve() { + return { memories: [] }; + }, + async write() { + return { saved: 0, analyzed: 0, memories: 0 }; + }, + async compact() { + return { analyzed: 0, memories: 0 }; + }, + }, + { + name: 'pgvector', + isAvailable() { + return false; + }, + async resolve() { + return { memories: [] }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_PROFILE_ENABLED: '0', + MEMORY_EVENT_LOG_ENABLED: '1', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_FAIL_OPEN: '1', + }, + }); + + assert.deepEqual(memory.getStatus(), { + enabled: true, + backend: 'pgvector', + selectedBackend: 'legacy-conversation-memory', + profileEnabled: false, + eventLogEnabled: true, + vectorEnabled: true, + failOpen: true, + backends: [ + { + name: 'legacy-conversation-memory', + available: true, + supports: { + resolve: true, + write: true, + compact: true, + }, + }, + { + name: 'pgvector', + available: false, + supports: { + resolve: true, + write: false, + compact: false, + }, + }, + ], + }); +}); diff --git a/package.json b/package.json index cb95646..62a73ef 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,18 @@ "check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only", "check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", + "check:memory-v2": "node scripts/check-memory-v2-health.mjs", + "canary:memory-v2-app": "node scripts/check-memory-v2-app-canary.mjs", + "check:memory-v2-contracts": "node scripts/check-memory-v2-contracts.mjs", + "check:memory-v2-config": "node scripts/check-memory-v2-config-gaps.mjs", + "check:memory-v2-session": "node scripts/check-memory-v2-session-flow.mjs", + "check:memory-v2-stack": "node scripts/check-memory-v2-stack.mjs", + "scaffold:memory-v2-backend": "node scripts/scaffold-memory-v2-backend.mjs", + "smoke:memory-v2-pgvector": "node scripts/smoke-memory-v2-pgvector.mjs", + "smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs", + "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", + "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", diff --git a/scripts/backfill-memory-v2-pgvector.mjs b/scripts/backfill-memory-v2-pgvector.mjs new file mode 100644 index 0000000..235d153 --- /dev/null +++ b/scripts/backfill-memory-v2-pgvector.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { backfillLegacyMemoriesToPgvector } from '../memory-v2-pgvector-backfill.mjs'; + +const DEFAULT_MYSQL_URL_ENV = 'MEMORY_BACKFILL_MYSQL_URL'; +const DEFAULT_PG_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL'; + +function usage() { + return ` +Usage: + node scripts/backfill-memory-v2-pgvector.mjs [options] + +Default mode is dry-run: scan MySQL source rows, but do not embed or write PostgreSQL. + +Options: + --apply Write embeddings into pgvector. + --limit Batch size. Default: 100. + --cursor-updated-at Checkpoint updated_at value. Default: 0. + --cursor-id Checkpoint id value. Default: empty. + --table pgvector table. Default: memory_embeddings. + --mysql-url-env Env var containing MySQL URL. Default: ${DEFAULT_MYSQL_URL_ENV}. + --pg-url-env Env var containing PostgreSQL URL. Default: ${DEFAULT_PG_URL_ENV}. + --embedding-module Required with --apply. Must export embedMemory(memory) or default. + -h, --help Show this help. +`.trim(); +} + +export function parseMemoryPgvectorBackfillArgs(argv = []) { + const options = { + apply: false, + limit: 100, + cursor: { updatedAt: 0, id: '' }, + tableName: 'memory_embeddings', + mysqlUrlEnv: DEFAULT_MYSQL_URL_ENV, + pgUrlEnv: DEFAULT_PG_URL_ENV, + embeddingModule: null, + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case '--apply': + options.apply = true; + break; + case '--limit': + i += 1; + options.limit = Number(argv[i]); + break; + case '--cursor-updated-at': + i += 1; + options.cursor.updatedAt = Number(argv[i]); + break; + case '--cursor-id': + i += 1; + options.cursor.id = String(argv[i] ?? ''); + break; + case '--table': + i += 1; + options.tableName = argv[i]; + break; + case '--mysql-url-env': + i += 1; + options.mysqlUrlEnv = argv[i]; + break; + case '--pg-url-env': + i += 1; + options.pgUrlEnv = argv[i]; + break; + case '--embedding-module': + i += 1; + options.embeddingModule = argv[i]; + break; + case '-h': + case '--help': + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!Number.isFinite(options.limit)) throw new Error('--limit requires a number'); + if (!Number.isFinite(options.cursor.updatedAt)) { + throw new Error('--cursor-updated-at requires a number'); + } + if (!options.tableName) throw new Error('--table requires a value'); + if (!options.mysqlUrlEnv) throw new Error('--mysql-url-env requires a value'); + if (!options.pgUrlEnv) throw new Error('--pg-url-env requires a value'); + return options; +} + +async function defaultCreateMysqlPool(connectionString) { + const mysql = await import('mysql2/promise'); + return mysql.createPool(connectionString); +} + +async function defaultCreatePgPool(connectionString) { + const { default: pg } = await import('pg'); + return new pg.Pool({ connectionString, max: 1 }); +} + +async function defaultLoadEmbedMemory(modulePath) { + if (!modulePath) return null; + const resolved = path.isAbsolute(modulePath) + ? modulePath + : path.resolve(process.cwd(), modulePath); + const mod = await import(pathToFileURL(resolved).href); + const embedMemory = mod.embedMemory ?? mod.default; + if (typeof embedMemory !== 'function') { + throw new Error('--embedding-module must export embedMemory(memory) or default function'); + } + return embedMemory; +} + +export async function runMemoryPgvectorBackfillCli( + argv = process.argv.slice(2), + env = process.env, + deps = {}, +) { + const options = parseMemoryPgvectorBackfillArgs(argv); + if (options.help) { + console.log(usage()); + return { ok: true, mode: 'help' }; + } + + const mysqlUrl = env[options.mysqlUrlEnv]; + if (!mysqlUrl) { + throw new Error(`Backfill requires ${options.mysqlUrlEnv} to be set`); + } + if (options.apply && !env[options.pgUrlEnv]) { + throw new Error(`--apply requires ${options.pgUrlEnv} to be set`); + } + if (options.apply && !options.embeddingModule) { + throw new Error('--apply requires --embedding-module'); + } + + const createMysqlPool = deps.createMysqlPool ?? defaultCreateMysqlPool; + const createPgPool = deps.createPgPool ?? defaultCreatePgPool; + const loadEmbedMemory = deps.loadEmbedMemory ?? defaultLoadEmbedMemory; + + const mysqlPool = await createMysqlPool(mysqlUrl); + let pgPool = null; + try { + let embedMemory = null; + if (options.apply) { + pgPool = await createPgPool(env[options.pgUrlEnv]); + embedMemory = await loadEmbedMemory(options.embeddingModule); + } + const result = await backfillLegacyMemoriesToPgvector({ + mysqlPool, + pgPool, + embedMemory, + tableName: options.tableName, + cursor: options.cursor, + limit: options.limit, + dryRun: !options.apply, + }); + console.log(JSON.stringify(result, null, 2)); + return result; + } finally { + await mysqlPool?.end?.(); + await pgPool?.end?.(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryPgvectorBackfillCli().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/backfill-memory-v2-pgvector.test.mjs b/scripts/backfill-memory-v2-pgvector.test.mjs new file mode 100644 index 0000000..db5d205 --- /dev/null +++ b/scripts/backfill-memory-v2-pgvector.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryPgvectorBackfillArgs, + runMemoryPgvectorBackfillCli, +} from './backfill-memory-v2-pgvector.mjs'; + +function createMysqlPool(rows, closed) { + return { + async query() { + return [rows]; + }, + async end() { + closed.mysql = true; + }, + }; +} + +function createPgPool(calls, closed) { + return { + async query(sql, params) { + calls.push({ sql, params }); + return { rows: [] }; + }, + async end() { + closed.pg = true; + }, + }; +} + +const rows = [ + { + id: 'mem-1', + user_id: 'user-1', + label: 'fact', + memory_text: '用户关注 Memory V2', + status: 'active', + created_at: 1000, + updated_at: 2000, + }, +]; + +test('parseMemoryPgvectorBackfillArgs defaults to safe dry-run settings', () => { + assert.deepEqual(parseMemoryPgvectorBackfillArgs([]), { + apply: false, + limit: 100, + cursor: { updatedAt: 0, id: '' }, + tableName: 'memory_embeddings', + mysqlUrlEnv: 'MEMORY_BACKFILL_MYSQL_URL', + pgUrlEnv: 'MEMORY_PGVECTOR_DATABASE_URL', + embeddingModule: null, + help: false, + }); +}); + +test('parseMemoryPgvectorBackfillArgs maps checkpoint and apply options', () => { + assert.deepEqual( + parseMemoryPgvectorBackfillArgs([ + '--apply', + '--limit', + '25', + '--cursor-updated-at', + '12345', + '--cursor-id', + 'mem-9', + '--table', + 'memory_embeddings_local', + '--mysql-url-env', + 'LOCAL_MYSQL_URL', + '--pg-url-env', + 'LOCAL_PG_URL', + '--embedding-module', + './embed-memory.mjs', + ]), + { + apply: true, + limit: 25, + cursor: { updatedAt: 12345, id: 'mem-9' }, + tableName: 'memory_embeddings_local', + mysqlUrlEnv: 'LOCAL_MYSQL_URL', + pgUrlEnv: 'LOCAL_PG_URL', + embeddingModule: './embed-memory.mjs', + help: false, + }, + ); +}); + +test('runMemoryPgvectorBackfillCli dry-run scans MySQL and does not open PostgreSQL', async () => { + const closed = { mysql: false, pg: false }; + let pgOpened = false; + const lines = []; + const originalLog = console.log; + console.log = (line = '') => { + lines.push(String(line)); + }; + try { + const result = await runMemoryPgvectorBackfillCli( + ['--limit', '10'], + { MEMORY_BACKFILL_MYSQL_URL: 'mysql://local' }, + { + createMysqlPool: async () => createMysqlPool(rows, closed), + createPgPool: async () => { + pgOpened = true; + return createPgPool([], closed); + }, + }, + ); + assert.equal(result.mode, 'dry-run'); + assert.equal(result.scanned, 1); + assert.equal(result.inserted, 0); + assert.equal(pgOpened, false); + assert.equal(closed.mysql, true); + assert.match(lines.join('\n'), /"mode": "dry-run"/); + } finally { + console.log = originalLog; + } +}); + +test('runMemoryPgvectorBackfillCli apply requires pg url and embedding module', async () => { + await assert.rejects( + () => runMemoryPgvectorBackfillCli( + ['--apply'], + { MEMORY_BACKFILL_MYSQL_URL: 'mysql://local' }, + { createMysqlPool: async () => createMysqlPool(rows, {}) }, + ), + /MEMORY_PGVECTOR_DATABASE_URL/, + ); + await assert.rejects( + () => runMemoryPgvectorBackfillCli( + ['--apply'], + { + MEMORY_BACKFILL_MYSQL_URL: 'mysql://local', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local', + }, + { createMysqlPool: async () => createMysqlPool(rows, {}) }, + ), + /--embedding-module/, + ); +}); + +test('runMemoryPgvectorBackfillCli apply embeds and closes both pools', async () => { + const pgCalls = []; + const closed = { mysql: false, pg: false }; + const originalLog = console.log; + console.log = () => {}; + try { + const result = await runMemoryPgvectorBackfillCli( + ['--apply', '--embedding-module', './embed-memory.mjs'], + { + MEMORY_BACKFILL_MYSQL_URL: 'mysql://local', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local', + }, + { + createMysqlPool: async () => createMysqlPool(rows, closed), + createPgPool: async () => createPgPool(pgCalls, closed), + loadEmbedMemory: async () => async () => [0.1, 0.2, 0.3], + }, + ); + assert.equal(result.mode, 'apply'); + assert.equal(result.inserted, 1); + assert.equal(pgCalls.length, 1); + assert.equal(closed.mysql, true); + assert.equal(closed.pg, true); + } finally { + console.log = originalLog; + } +}); diff --git a/scripts/check-memory-v2-app-canary.mjs b/scripts/check-memory-v2-app-canary.mjs new file mode 100644 index 0000000..4907a12 --- /dev/null +++ b/scripts/check-memory-v2-app-canary.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +function normalizeBaseUrl(value) { + return String(value ?? '').trim().replace(/\/+$/, ''); +} + +function requireValue(name, value) { + if (!value) throw new Error(`${name} requires a value`); + return value; +} + +export function parseMemoryV2AppCanaryArgs(argv = [], env = process.env) { + const options = { + baseUrl: normalizeBaseUrl(env.MEMORY_V2_APP_BASE_URL || 'http://127.0.0.1:8081'), + requireEnabled: false, + requireTargetHealthy: false, + expectedBackend: null, + expectedSelectedBackend: null, + timeoutMs: Number(env.MEMORY_V2_APP_CANARY_TIMEOUT_MS || 8000), + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base-url') { + options.baseUrl = normalizeBaseUrl(requireValue(arg, argv[++i])); + } else if (arg === '--require-enabled') { + options.requireEnabled = true; + } else if (arg === '--require-target-healthy') { + options.requireTargetHealthy = true; + } else if (arg === '--expect-backend') { + options.expectedBackend = requireValue(arg, argv[++i]); + } else if (arg === '--expect-selected-backend') { + options.expectedSelectedBackend = requireValue(arg, argv[++i]); + } else if (arg === '--timeout-ms') { + options.timeoutMs = Number(requireValue(arg, argv[++i])); + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.baseUrl) throw new Error('--base-url is required'); + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number'); + } + return options; +} + +function usage() { + return `Usage: + node scripts/check-memory-v2-app-canary.mjs [options] + +Options: + --base-url Portal base URL. Defaults to MEMORY_V2_APP_BASE_URL or http://127.0.0.1:8081. + --require-enabled Fail unless /api/runtime/status reports memory.enabled=true. + --expect-backend Fail unless memory.backend equals this value. + --expect-selected-backend Fail unless memory.selectedBackend equals this value. + --require-target-healthy Fail unless at least one runtime target is healthy. + --timeout-ms Request timeout. Defaults to 8000. +`; +} + +async function fetchJson(fetchImpl, url, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { + ok: response.ok, + status: response.status, + text, + json, + }; + } finally { + clearTimeout(timer); + } +} + +function check(name, ok, details = {}) { + return { name, ok: Boolean(ok), ...details }; +} + +export async function runMemoryV2AppCanaryCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, + fetchImpl = globalThis.fetch, +} = {}) { + const options = parseMemoryV2AppCanaryArgs(argv, env); + if (options.help) { + stdout.write(usage()); + return 0; + } + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is not available in this Node.js runtime'); + } + + const runtime = await fetchJson(fetchImpl, `${options.baseUrl}/api/runtime/status`, options.timeoutMs); + const apiStatus = await fetchJson(fetchImpl, `${options.baseUrl}/api/status`, options.timeoutMs); + const authStatus = await fetchJson(fetchImpl, `${options.baseUrl}/auth/status`, options.timeoutMs); + + const memory = runtime.json?.memory ?? null; + const targets = Array.isArray(runtime.json?.targets) ? runtime.json.targets : []; + const healthyTargets = targets.filter((target) => target?.healthy); + const checks = [ + check('runtime_status_http_ok', runtime.ok, { status: runtime.status }), + check('runtime_status_ok', runtime.json?.ok === true), + check('memory_status_present', Boolean(memory)), + check('api_status_http_ok', apiStatus.ok, { status: apiStatus.status }), + check('auth_status_http_ok', authStatus.ok, { status: authStatus.status }), + ]; + + if (options.requireEnabled) { + checks.push(check('memory_enabled', memory?.enabled === true, { actual: memory?.enabled ?? null })); + } + if (options.expectedBackend) { + checks.push(check('memory_backend', memory?.backend === options.expectedBackend, { + expected: options.expectedBackend, + actual: memory?.backend ?? null, + })); + } + if (options.expectedSelectedBackend) { + checks.push(check('memory_selected_backend', memory?.selectedBackend === options.expectedSelectedBackend, { + expected: options.expectedSelectedBackend, + actual: memory?.selectedBackend ?? null, + })); + } + if (options.requireTargetHealthy) { + checks.push(check('runtime_target_healthy', healthyTargets.length > 0, { + healthyTargets: healthyTargets.length, + targets: targets.length, + })); + } + + const report = { + ok: checks.every((item) => item.ok), + baseUrl: options.baseUrl, + summary: { + memory: memory + ? { + enabled: memory.enabled, + backend: memory.backend, + selectedBackend: memory.selectedBackend, + failOpen: memory.failOpen, + vectorEnabled: memory.vectorEnabled, + } + : null, + targets: { + total: targets.length, + healthy: healthyTargets.length, + }, + apiStatus: { + status: apiStatus.status, + body: apiStatus.text.slice(0, 80), + }, + authStatus: { + status: authStatus.status, + authenticated: authStatus.json?.authenticated ?? null, + mode: authStatus.json?.mode ?? null, + }, + }, + checks, + }; + + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2AppCanaryCli().then((code) => { + process.exitCode = code; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/check-memory-v2-app-canary.test.mjs b/scripts/check-memory-v2-app-canary.test.mjs new file mode 100644 index 0000000..ded04e8 --- /dev/null +++ b/scripts/check-memory-v2-app-canary.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryV2AppCanaryArgs, + runMemoryV2AppCanaryCli, +} from './check-memory-v2-app-canary.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2AppCanaryArgs maps app-level gate options', () => { + assert.deepEqual( + parseMemoryV2AppCanaryArgs([ + '--base-url', + 'http://127.0.0.1:18081/', + '--require-enabled', + '--require-target-healthy', + '--expect-backend', + 'pgvector', + '--expect-selected-backend', + 'pgvector', + '--timeout-ms', + '1000', + ]), + { + baseUrl: 'http://127.0.0.1:18081', + requireEnabled: true, + requireTargetHealthy: true, + expectedBackend: 'pgvector', + expectedSelectedBackend: 'pgvector', + timeoutMs: 1000, + help: false, + }, + ); +}); + +test('runMemoryV2AppCanaryCli passes against a pgvector-enabled runtime status', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2AppCanaryCli({ + argv: [ + '--base-url', + 'http://app.local', + '--require-enabled', + '--require-target-healthy', + '--expect-backend', + 'pgvector', + '--expect-selected-backend', + 'pgvector', + ], + stdout, + async fetchImpl(url) { + if (url.endsWith('/api/runtime/status')) { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + ok: true, + memory: { + enabled: true, + backend: 'pgvector', + selectedBackend: 'pgvector', + failOpen: true, + vectorEnabled: true, + }, + targets: [{ target: 'https://127.0.0.1:18006', healthy: true }], + }); + }, + }; + } + if (url.endsWith('/api/status')) { + return { ok: true, status: 200, async text() { return 'ok'; } }; + } + if (url.endsWith('/auth/status')) { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ authenticated: false, mode: 'user' }); + }, + }; + } + throw new Error(`unexpected url ${url}`); + }, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(report.ok, true); + assert.equal(report.summary.memory.selectedBackend, 'pgvector'); + assert.equal(report.summary.targets.healthy, 1); +}); + +test('runMemoryV2AppCanaryCli fails when selected backend does not match', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2AppCanaryCli({ + argv: ['--expect-selected-backend', 'pgvector'], + stdout, + async fetchImpl(url) { + if (url.endsWith('/api/runtime/status')) { + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + ok: true, + memory: { enabled: true, backend: 'pgvector', selectedBackend: 'legacy-conversation-memory' }, + targets: [], + }); + }, + }; + } + return { ok: true, status: 200, async text() { return '{}'; } }; + }, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 1); + assert.equal(report.ok, false); + assert.equal(report.checks.find((item) => item.name === 'memory_selected_backend').ok, false); +}); diff --git a/scripts/check-memory-v2-config-gaps.mjs b/scripts/check-memory-v2-config-gaps.mjs new file mode 100644 index 0000000..5eb6afd --- /dev/null +++ b/scripts/check-memory-v2-config-gaps.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +const BACKEND_SPECS = [ + { + name: 'pgvector', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_VECTOR_ENABLED', 'MEMORY_PGVECTOR_DATABASE_URL', 'MEMORY_PGVECTOR_EMBEDDING_MODULE'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://john@127.0.0.1:5432/memind_memory', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs', + }, + }, + { + name: 'qdrant', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_QDRANT_ENABLED', 'MEMORY_QDRANT_URL', 'MEMORY_QDRANT_EMBEDDING_MODULE'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'qdrant', + MEMORY_QDRANT_ENABLED: '1', + MEMORY_QDRANT_URL: 'http://127.0.0.1:6333', + MEMORY_QDRANT_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs', + }, + }, + { + name: 'weaviate', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_WEAVIATE_ENABLED', 'MEMORY_WEAVIATE_URL', 'MEMORY_WEAVIATE_EMBEDDING_MODULE'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'weaviate', + MEMORY_WEAVIATE_ENABLED: '1', + MEMORY_WEAVIATE_URL: 'http://127.0.0.1:8080', + MEMORY_WEAVIATE_EMBEDDING_MODULE: './scripts/embed-memory-v2-local-hash.mjs', + }, + }, + { + name: 'mem0', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_MEM0_ENABLED', 'MEMORY_MEM0_API_KEY'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'mem0', + MEMORY_MEM0_ENABLED: '1', + MEMORY_MEM0_API_KEY: 'replace-me', + }, + }, + { + name: 'letta', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_LETTA_ENABLED', 'MEMORY_LETTA_API_KEY', 'MEMORY_LETTA_AGENT_ID'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'letta', + MEMORY_LETTA_ENABLED: '1', + MEMORY_LETTA_API_KEY: 'replace-me', + MEMORY_LETTA_AGENT_ID: 'agent_1', + }, + }, + { + name: 'neo4j', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_NEO4J_ENABLED', 'MEMORY_NEO4J_HTTP_URL', 'MEMORY_NEO4J_USER', 'MEMORY_NEO4J_PASSWORD'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'neo4j', + MEMORY_NEO4J_ENABLED: '1', + MEMORY_NEO4J_HTTP_URL: 'http://127.0.0.1:7474', + MEMORY_NEO4J_USER: 'neo4j', + MEMORY_NEO4J_PASSWORD: 'replace-me', + }, + }, + { + name: 'redis-streams', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_REDIS_STREAMS_ENABLED', 'MEMORY_REDIS_STREAMS_URL'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'redis-streams', + MEMORY_REDIS_STREAMS_ENABLED: '1', + MEMORY_REDIS_STREAMS_URL: 'redis://127.0.0.1:6379', + }, + }, + { + name: 'langgraph', + env: ['MEMORY_ENABLED', 'MEMORY_BACKEND', 'MEMORY_LANGGRAPH_ENABLED', 'MEMORY_LANGGRAPH_URL'], + recommended: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'langgraph', + MEMORY_LANGGRAPH_ENABLED: '1', + MEMORY_LANGGRAPH_URL: 'http://127.0.0.1:2024', + }, + }, +]; + +function usage() { + return `Usage: + node scripts/check-memory-v2-config-gaps.mjs [options] + +Options: + --backend Limit output to one backend. + --format json | shell. Default: json. + -h, --help Show this help. +`; +} + +export function parseMemoryV2ConfigGapArgs(argv = []) { + const options = { + backend: null, + format: 'json', + help: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--backend') { + options.backend = argv[++i] ?? ''; + } else if (arg === '--format') { + options.format = argv[++i] ?? ''; + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + if (options.backend && !BACKEND_SPECS.some((item) => item.name === options.backend)) { + throw new Error(`--backend must be one of: ${BACKEND_SPECS.map((item) => item.name).join(', ')}`); + } + if (!['json', 'shell'].includes(options.format)) { + throw new Error('--format must be json or shell'); + } + return options; +} + +function inspectBackend(spec, env = process.env) { + const present = {}; + const missing = []; + for (const key of spec.env) { + const value = String(env[key] ?? '').trim(); + if (value) present[key] = value; + else missing.push(key); + } + return { + backend: spec.name, + ready: missing.length === 0, + missing, + presentKeys: Object.keys(present), + recommended: spec.recommended, + }; +} + +function renderShell(reports) { + const chunks = []; + for (const report of reports) { + chunks.push(`# ${report.backend}`); + for (const [key, value] of Object.entries(report.recommended)) { + chunks.push(`export ${key}='${String(value).replaceAll("'", "'\\''")}'`); + } + chunks.push(''); + } + return chunks.join('\n').trimEnd(); +} + +export async function runMemoryV2ConfigGapCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, +} = {}) { + const options = parseMemoryV2ConfigGapArgs(argv); + if (options.help) { + stdout.write(usage()); + return 0; + } + + const reports = BACKEND_SPECS + .filter((spec) => !options.backend || spec.name === options.backend) + .map((spec) => inspectBackend(spec, env)); + + if (options.format === 'shell') { + stdout.write(`${renderShell(reports)}\n`); + return reports.every((item) => item.ready) ? 0 : 1; + } + + const output = { + ok: reports.every((item) => item.ready), + checkedAt: new Date().toISOString(), + reports, + }; + stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return output.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2ConfigGapCli().then((code) => { + process.exitCode = code; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/check-memory-v2-config-gaps.test.mjs b/scripts/check-memory-v2-config-gaps.test.mjs new file mode 100644 index 0000000..144b5c4 --- /dev/null +++ b/scripts/check-memory-v2-config-gaps.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryV2ConfigGapArgs, + runMemoryV2ConfigGapCli, +} from './check-memory-v2-config-gaps.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2ConfigGapArgs validates options', () => { + assert.deepEqual( + parseMemoryV2ConfigGapArgs(['--backend', 'qdrant', '--format', 'shell']), + { backend: 'qdrant', format: 'shell', help: false }, + ); + assert.throws(() => parseMemoryV2ConfigGapArgs(['--backend', 'bad']), /--backend must be one of/); + assert.throws(() => parseMemoryV2ConfigGapArgs(['--format', 'xml']), /--format must be json or shell/); +}); + +test('runMemoryV2ConfigGapCli reports missing env vars in json mode', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2ConfigGapCli({ + argv: ['--backend', 'mem0'], + env: {}, + stdout, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 1); + assert.equal(report.ok, false); + assert.equal(report.reports[0].backend, 'mem0'); + assert.ok(report.reports[0].missing.includes('MEMORY_MEM0_API_KEY')); +}); + +test('runMemoryV2ConfigGapCli emits export template in shell mode', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2ConfigGapCli({ + argv: ['--backend', 'qdrant', '--format', 'shell'], + env: {}, + stdout, + }); + + assert.equal(code, 1); + assert.match(stdout.value(), /export MEMORY_BACKEND='qdrant'/); + assert.match(stdout.value(), /export MEMORY_QDRANT_URL='http:\/\/127\.0\.0\.1:6333'/); +}); diff --git a/scripts/check-memory-v2-contracts.mjs b/scripts/check-memory-v2-contracts.mjs new file mode 100644 index 0000000..b42691c --- /dev/null +++ b/scripts/check-memory-v2-contracts.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { createLegacyMemoryBackend } from '../memory-v2.mjs'; +import { createMemoryV2PluginBackends } from '../memory-v2-plugin-backends.mjs'; +import { createPgvectorMemoryBackend } from '../memory-v2-pgvector.mjs'; +import { validateMemoryV2BackendSet } from '../memory-v2-backend-contract.mjs'; + +function usage() { + return `Usage: + node scripts/check-memory-v2-contracts.mjs [options] + +Options: + --include-pgvector-adapter Include the real disabled pgvector adapter instead of its placeholder. + -h, --help Show this help. +`; +} + +export function parseMemoryV2ContractArgs(argv = []) { + const options = { + includePgvectorAdapter: false, + help: false, + }; + for (const arg of argv) { + if (arg === '--include-pgvector-adapter') { + options.includePgvectorAdapter = true; + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function createSyntheticLegacyMemoryService() { + return { + async listMemories() { + return []; + }, + async saveAndAnalyze() { + return { saved: 0, analyzed: 0, memories: 0 }; + }, + async analyzeUser() { + return { analyzed: 0, memories: 0 }; + }, + }; +} + +export async function runMemoryV2ContractCli({ + argv = process.argv.slice(2), + stdout = process.stdout, +} = {}) { + const options = parseMemoryV2ContractArgs(argv); + if (options.help) { + stdout.write(usage()); + return 0; + } + + const backends = [ + createLegacyMemoryBackend(createSyntheticLegacyMemoryService()), + ]; + if (options.includePgvectorAdapter) { + backends.push(createPgvectorMemoryBackend({ enabled: false })); + } + backends.push(...createMemoryV2PluginBackends({ + exclude: options.includePgvectorAdapter ? ['pgvector'] : [], + })); + + const report = validateMemoryV2BackendSet(backends); + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2ContractCli().then((code) => { + process.exitCode = code; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/check-memory-v2-contracts.test.mjs b/scripts/check-memory-v2-contracts.test.mjs new file mode 100644 index 0000000..f807d30 --- /dev/null +++ b/scripts/check-memory-v2-contracts.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryV2ContractArgs, + runMemoryV2ContractCli, +} from './check-memory-v2-contracts.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2ContractArgs parses options', () => { + assert.deepEqual(parseMemoryV2ContractArgs(['--include-pgvector-adapter']), { + includePgvectorAdapter: true, + help: false, + }); + assert.deepEqual(parseMemoryV2ContractArgs(['--help']), { + includePgvectorAdapter: false, + help: true, + }); +}); + +test('runMemoryV2ContractCli passes for default backend contracts', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2ContractCli({ stdout }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(report.ok, true); + assert.equal(report.summary.backendCount, 9); + assert.equal(report.summary.availableBackends[0], 'legacy-conversation-memory'); +}); + +test('runMemoryV2ContractCli passes with real disabled pgvector adapter', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2ContractCli({ + argv: ['--include-pgvector-adapter'], + stdout, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(report.ok, true); + assert.equal(report.backends.find((backend) => backend.name === 'pgvector').category, 'semantic'); +}); diff --git a/scripts/check-memory-v2-health.mjs b/scripts/check-memory-v2-health.mjs new file mode 100644 index 0000000..ea4f8a2 --- /dev/null +++ b/scripts/check-memory-v2-health.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { createMemoryV2Runtime } from '../memory-v2-runtime.mjs'; +import { evaluateMemoryV2Health } from '../memory-v2-health.mjs'; + +function loadEnvFile(filePath, env = process.env) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!env[key]) env[key] = value; + } +} + +export function parseMemoryV2HealthArgs(argv = []) { + const options = { + requireEnabled: false, + expectedBackend: null, + envFile: process.env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'), + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--require-enabled') { + options.requireEnabled = true; + } else if (arg === '--expect-backend') { + options.expectedBackend = argv[++i] ?? ''; + } else if (arg === '--no-env-file') { + options.envFile = ''; + } else if (arg === '--env-file') { + options.envFile = argv[++i] ?? ''; + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function usage() { + return `Usage: + node scripts/check-memory-v2-health.mjs [options] + +Options: + --require-enabled Fail unless Memory V2 is enabled. + --expect-backend Fail unless MEMORY_BACKEND equals this value. + --env-file Env file to load. Defaults to MEMIND_ENV_FILE or .env. + --no-env-file Do not load an env file. +`; +} + +function createSyntheticLegacyMemoryService() { + return { + async listMemories() { + return [{ label: 'health', text: 'synthetic memory-v2 health check' }]; + }, + async saveAndAnalyze() { + return { saved: 1, analyzed: 1, memories: 1 }; + }, + async analyzeUser() { + return { analyzed: 1, memories: 1 }; + }, + }; +} + +export async function runMemoryV2HealthCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, + stderr = process.stderr, + importPg, + importModule, +} = {}) { + const options = parseMemoryV2HealthArgs(argv); + if (options.help) { + stdout.write(usage()); + return 0; + } + if (options.envFile) loadEnvFile(options.envFile, env); + + const memory = await createMemoryV2Runtime({ + legacyMemoryService: createSyntheticLegacyMemoryService(), + env, + logger: { + warn(message) { + stderr.write(`${message}\n`); + }, + }, + ...(importPg ? { importPg } : {}), + ...(importModule ? { importModule } : {}), + }); + + const status = memory.getStatus(); + const writeResult = await memory.write({ + userId: 'memory-v2-health-user', + sessionId: 'memory-v2-health-session', + messages: [], + }); + const compactResult = await memory.compact({ + userId: 'memory-v2-health-user', + sessionId: 'memory-v2-health-session', + }); + + await memory.close?.(); + + const report = evaluateMemoryV2Health({ + status, + writeResult, + compactResult, + expectedBackend: options.expectedBackend || null, + requireEnabled: options.requireEnabled, + }); + + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2HealthCli().then((code) => { + process.exitCode = code; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/check-memory-v2-health.test.mjs b/scripts/check-memory-v2-health.test.mjs new file mode 100644 index 0000000..1729293 --- /dev/null +++ b/scripts/check-memory-v2-health.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseMemoryV2HealthArgs, runMemoryV2HealthCli } from './check-memory-v2-health.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2HealthArgs parses release gate options', () => { + assert.deepEqual( + parseMemoryV2HealthArgs([ + '--require-enabled', + '--expect-backend', + 'legacy', + '--no-env-file', + '--env-file', + '.env.memory', + ]), + { + requireEnabled: true, + expectedBackend: 'legacy', + envFile: '.env.memory', + }, + ); +}); + +test('runMemoryV2HealthCli passes with default legacy-safe runtime', async () => { + const stdout = writableBuffer(); + const stderr = writableBuffer(); + const code = await runMemoryV2HealthCli({ + argv: ['--require-enabled', '--expect-backend', 'legacy', '--no-env-file'], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'legacy', + MEMORY_FAIL_OPEN: '1', + }, + stdout, + stderr, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(report.ok, true); + assert.equal(report.summary.backend, 'legacy'); + assert.equal(stderr.value(), ''); +}); + +test('runMemoryV2HealthCli fails when enabled is required but disabled', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2HealthCli({ + argv: ['--require-enabled', '--no-env-file'], + env: { + MEMORY_ENABLED: '0', + MEMORY_BACKEND: 'legacy', + }, + stdout, + stderr: writableBuffer(), + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 1); + assert.equal(report.ok, false); + assert.equal(report.checks.find((check) => check.name === 'memory_enabled').ok, false); +}); + +test('runMemoryV2HealthCli verifies pgvector resolve canary keeps legacy writes', async () => { + const stdout = writableBuffer(); + let poolEnded = false; + class FakePool { + async query() { + return { rows: [] }; + } + + async end() { + poolEnded = true; + } + } + + const code = await runMemoryV2HealthCli({ + argv: ['--require-enabled', '--expect-backend', 'pgvector', '--no-env-file'], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'pgvector', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs', + }, + stdout, + stderr: writableBuffer(), + async importPg() { + return { Pool: FakePool }; + }, + async importModule() { + return { + async embedQuery() { + return [0.1, 0.2, 0.3]; + }, + }; + }, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(poolEnded, true); + assert.equal(report.summary.selectedBackend, 'pgvector'); + assert.equal(report.checks.find((check) => check.name === 'write_uses_legacy').ok, true); + assert.equal(report.checks.find((check) => check.name === 'compact_uses_legacy').ok, true); +}); diff --git a/scripts/check-memory-v2-session-flow.mjs b/scripts/check-memory-v2-session-flow.mjs new file mode 100644 index 0000000..7e476f5 --- /dev/null +++ b/scripts/check-memory-v2-session-flow.mjs @@ -0,0 +1,344 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; + +function normalizeBaseUrl(value) { + return String(value ?? '').trim().replace(/\/+$/, ''); +} + +function requireValue(name, value) { + if (!value) throw new Error(`${name} requires a value`); + return value; +} + +export function parseMemoryV2SessionFlowArgs(argv = [], env = process.env) { + const options = { + baseUrl: normalizeBaseUrl(env.MEMORY_V2_SESSION_FLOW_BASE_URL || 'http://127.0.0.1:8081'), + prompt: env.MEMORY_V2_SESSION_FLOW_PROMPT || 'Please confirm Memory V2 session flow is working.', + expectedBackend: null, + expectedSelectedBackend: null, + timeoutMs: Number(env.MEMORY_V2_SESSION_FLOW_TIMEOUT_MS || 45000), + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base-url') { + options.baseUrl = normalizeBaseUrl(requireValue(arg, argv[++i])); + } else if (arg === '--prompt') { + options.prompt = requireValue(arg, argv[++i]); + } else if (arg === '--expect-backend') { + options.expectedBackend = requireValue(arg, argv[++i]); + } else if (arg === '--expect-selected-backend') { + options.expectedSelectedBackend = requireValue(arg, argv[++i]); + } else if (arg === '--timeout-ms') { + options.timeoutMs = Number(requireValue(arg, argv[++i])); + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.baseUrl) throw new Error('--base-url is required'); + if (!options.prompt) throw new Error('--prompt is required'); + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number'); + } + return options; +} + +function usage() { + return `Usage: + node scripts/check-memory-v2-session-flow.mjs [options] + +Options: + --base-url Portal base URL. Defaults to MEMORY_V2_SESSION_FLOW_BASE_URL or http://127.0.0.1:8081. + --prompt User prompt sent through the live session. + --expect-backend Fail unless runtime memory.backend equals this value. + --expect-selected-backend Fail unless runtime memory.selectedBackend equals this value. + --timeout-ms Request timeout. Defaults to 45000. +`; +} + +function makeCheck(name, ok, details = {}) { + return { name, ok: Boolean(ok), ...details }; +} + +async function parseResponseBody(response) { + const contentType = response.headers?.get?.('content-type') ?? ''; + const text = await response.text(); + if (contentType.includes('application/json')) { + try { + return { text, json: text ? JSON.parse(text) : null }; + } catch { + return { text, json: null }; + } + } + try { + return { text, json: text ? JSON.parse(text) : null }; + } catch { + return { text, json: null }; + } +} + +async function requestJson(fetchImpl, url, { method = 'GET', headers = {}, body, timeoutMs = 45000 } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + const payload = await parseResponseBody(response); + return { ok: response.ok, status: response.status, ...payload, headers: response.headers }; + } finally { + clearTimeout(timer); + } +} + +function buildTempUser() { + const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`; + return { + username: `mv2_${suffix}`, + password: 'MemoryV2-Session-2026', + email: `mv2-${suffix}@example.test`, + displayName: `mv2_${suffix}`, + }; +} + +async function waitForSessionFinish(fetchImpl, baseUrl, sessionId, cookie, timeoutMs, runTrigger) { + const response = await fetchImpl(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events`, { + headers: { + Accept: 'text/event-stream', + Cookie: cookie, + }, + }); + if (!response.ok || !response.body) { + const payload = await parseResponseBody(response); + throw new Error(`session events failed: ${response.status} ${payload.text}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const seen = []; + const runId = await runTrigger(); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + for (const chunk of chunks) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + seen.push(trimmed); + if (trimmed.includes('type":"Error"')) { + throw new Error(`session stream error: ${trimmed}`); + } + if (trimmed.includes('type":"Finish"')) { + await reader.cancel().catch(() => {}); + return { runId, seen }; + } + } + } + + await reader.cancel().catch(() => {}); + throw new Error(`session stream timeout after ${timeoutMs}ms`); +} + +export async function runMemoryV2SessionFlowCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, + fetchImpl = globalThis.fetch, +} = {}) { + const options = parseMemoryV2SessionFlowArgs(argv, env); + if (options.help) { + stdout.write(usage()); + return 0; + } + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is not available in this Node.js runtime'); + } + + const checks = []; + const user = buildTempUser(); + + const register = await requestJson(fetchImpl, `${options.baseUrl}/auth/register`, { + method: 'POST', + body: user, + timeoutMs: options.timeoutMs, + }); + checks.push(makeCheck('register_http_ok', register.ok, { status: register.status })); + if (!register.ok) { + const report = { ok: false, baseUrl: options.baseUrl, checks, error: register.json ?? register.text }; + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return 1; + } + + const login = await requestJson(fetchImpl, `${options.baseUrl}/auth/login`, { + method: 'POST', + body: { username: user.username, password: user.password }, + timeoutMs: options.timeoutMs, + }); + const cookie = login.headers?.get?.('set-cookie')?.split(';', 1)[0] ?? null; + checks.push(makeCheck('login_http_ok', login.ok && Boolean(cookie), { + status: login.status, + hasCookie: Boolean(cookie), + })); + if (!login.ok || !cookie) { + const report = { ok: false, baseUrl: options.baseUrl, checks, error: login.json ?? login.text }; + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return 1; + } + + const started = await requestJson(fetchImpl, `${options.baseUrl}/api/agent/start`, { + method: 'POST', + body: {}, + headers: { Cookie: cookie }, + timeoutMs: options.timeoutMs, + }); + const sessionId = started.json?.id ?? null; + checks.push(makeCheck('agent_start_ok', started.ok && Boolean(sessionId), { + status: started.status, + sessionId, + })); + if (!started.ok || !sessionId) { + const report = { ok: false, baseUrl: options.baseUrl, checks, error: started.json ?? started.text }; + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return 1; + } + + const requestId = crypto.randomUUID(); + const { runId, seen } = await waitForSessionFinish( + fetchImpl, + options.baseUrl, + sessionId, + cookie, + options.timeoutMs, + async () => { + const created = await requestJson(fetchImpl, `${options.baseUrl}/api/agent/runs`, { + method: 'POST', + headers: { Cookie: cookie }, + body: { + session_id: sessionId, + request_id: requestId, + user_message: { + role: 'user', + created: Math.floor(Date.now() / 1000), + content: [{ type: 'text', text: options.prompt }], + metadata: { userVisible: true, agentVisible: true }, + }, + }, + timeoutMs: options.timeoutMs, + }); + checks.push(makeCheck('agent_run_created', created.ok && created.status === 202, { + status: created.status, + runId: created.json?.run?.id ?? null, + })); + if (!created.ok || created.status !== 202 || !created.json?.run?.id) { + throw new Error(`agent run failed: ${created.status} ${created.text}`); + } + return created.json.run.id; + }, + ); + checks.push(makeCheck('session_finish_seen', seen.some((chunk) => chunk.includes('type":"Finish"')), { + eventCount: seen.length, + })); + + const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { + headers: { Cookie: cookie }, + timeoutMs: options.timeoutMs, + }); + const userVisibleMessages = Array.isArray(detail.json?.conversation) + ? detail.json.conversation.filter((message) => message?.metadata?.userVisible) + : []; + const assistantMessages = userVisibleMessages.filter((message) => message?.role === 'assistant'); + checks.push(makeCheck('session_detail_assistant_reply', detail.ok && assistantMessages.length > 0, { + status: detail.status, + userVisibleCount: userVisibleMessages.length, + assistantCount: assistantMessages.length, + })); + + const remember = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/remember-recent`, { + method: 'POST', + headers: { Cookie: cookie }, + body: { sessionId }, + timeoutMs: options.timeoutMs, + }); + checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, { + status: remember.status, + })); + + const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, { + method: 'POST', + headers: { Cookie: cookie }, + body: { sessionId }, + timeoutMs: options.timeoutMs, + }); + checks.push(makeCheck('sync_ok', sync.ok && sync.json?.ok === true, { + status: sync.status, + })); + + const runtime = await requestJson(fetchImpl, `${options.baseUrl}/api/runtime/status`, { + timeoutMs: options.timeoutMs, + }); + const memory = runtime.json?.memory ?? null; + if (options.expectedBackend) { + checks.push(makeCheck('memory_backend', memory?.backend === options.expectedBackend, { + expected: options.expectedBackend, + actual: memory?.backend ?? null, + })); + } + if (options.expectedSelectedBackend) { + checks.push(makeCheck('memory_selected_backend', memory?.selectedBackend === options.expectedSelectedBackend, { + expected: options.expectedSelectedBackend, + actual: memory?.selectedBackend ?? null, + })); + } + + const report = { + ok: checks.every((item) => item.ok), + baseUrl: options.baseUrl, + user: user.username, + sessionId, + runId, + summary: { + assistantPreview: assistantMessages.at(-1)?.content?.[0]?.text?.slice?.(0, 200) ?? null, + remember: remember.json, + sync: sync.json, + runtimeMemory: memory + ? { + enabled: memory.enabled, + backend: memory.backend, + selectedBackend: memory.selectedBackend, + failOpen: memory.failOpen, + vectorEnabled: memory.vectorEnabled, + } + : null, + sessionEventsTail: seen.slice(-4), + }, + checks, + }; + + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2SessionFlowCli().then((code) => { + process.exitCode = code; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/check-memory-v2-session-flow.test.mjs b/scripts/check-memory-v2-session-flow.test.mjs new file mode 100644 index 0000000..6ad5aeb --- /dev/null +++ b/scripts/check-memory-v2-session-flow.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryV2SessionFlowArgs, + runMemoryV2SessionFlowCli, +} from './check-memory-v2-session-flow.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +function jsonResponse(body, { status = 200, headers = {} } = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get(name) { + return headers[name.toLowerCase()] ?? headers[name] ?? null; + }, + }, + async text() { + return JSON.stringify(body); + }, + }; +} + +function sseResponse(chunks) { + const encoded = chunks.map((chunk) => new TextEncoder().encode(chunk)); + return { + ok: true, + status: 200, + headers: { + get() { + return 'text/event-stream'; + }, + }, + body: new ReadableStream({ + start(controller) { + for (const chunk of encoded) controller.enqueue(chunk); + controller.close(); + }, + }), + async text() { + return ''; + }, + }; +} + +test('parseMemoryV2SessionFlowArgs maps session flow options', () => { + assert.deepEqual( + parseMemoryV2SessionFlowArgs([ + '--base-url', + 'http://127.0.0.1:18081/', + '--prompt', + 'hello', + '--expect-backend', + 'pgvector', + '--expect-selected-backend', + 'pgvector', + '--timeout-ms', + '5000', + ]), + { + baseUrl: 'http://127.0.0.1:18081', + prompt: 'hello', + expectedBackend: 'pgvector', + expectedSelectedBackend: 'pgvector', + timeoutMs: 5000, + help: false, + }, + ); +}); + +test('runMemoryV2SessionFlowCli passes against a mocked live session flow', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2SessionFlowCli({ + argv: [ + '--base-url', + 'http://app.local', + '--expect-backend', + 'pgvector', + '--expect-selected-backend', + 'pgvector', + ], + stdout, + async fetchImpl(url, init = {}) { + if (url.endsWith('/auth/register')) { + return jsonResponse({ ok: true, user: { id: 'user-1' } }); + } + if (url.endsWith('/auth/login')) { + return jsonResponse( + { authenticated: true, user: { id: 'user-1' } }, + { headers: { 'set-cookie': 'session=abc; Path=/; HttpOnly' } }, + ); + } + if (url.endsWith('/api/agent/start')) { + return jsonResponse({ id: 'session-1' }); + } + if (url.endsWith('/api/sessions/session-1/events')) { + return sseResponse([ + 'id: 1\ndata: {"type":"Message","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"metadata":{"userVisible":true}}}\n\n', + 'id: 2\ndata: {"type":"Finish","reason":"stop"}\n\n', + ]); + } + if (url.endsWith('/api/agent/runs')) { + assert.equal(init.method, 'POST'); + return jsonResponse({ run: { id: 'run-1' } }, { status: 202 }); + } + if (url.endsWith('/api/sessions/session-1')) { + return jsonResponse({ + conversation: [ + { + role: 'user', + metadata: { userVisible: true }, + content: [{ type: 'text', text: 'hello' }], + }, + { + role: 'assistant', + metadata: { userVisible: true }, + content: [{ type: 'text', text: 'hi' }], + }, + ], + }); + } + if (url.endsWith('/api/user-memory/v1/remember-recent')) { + return jsonResponse({ ok: true, analyzed: 1, memories: 0, totalMemories: 0, syncedToSession: true }); + } + if (url.endsWith('/api/user-memory/v1/sync')) { + return jsonResponse({ ok: true, analyzed: 1, memories: 0, totalMemories: 0, syncedToSession: true }); + } + if (url.endsWith('/api/runtime/status')) { + return jsonResponse({ + ok: true, + memory: { + enabled: true, + backend: 'pgvector', + selectedBackend: 'pgvector', + failOpen: true, + vectorEnabled: true, + }, + }); + } + throw new Error(`unexpected url ${url}`); + }, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(report.ok, true); + assert.equal(report.sessionId, 'session-1'); + assert.equal(report.runId, 'run-1'); + assert.equal(report.summary.runtimeMemory.selectedBackend, 'pgvector'); +}); diff --git a/scripts/check-memory-v2-stack.mjs b/scripts/check-memory-v2-stack.mjs new file mode 100644 index 0000000..9e1d3d6 --- /dev/null +++ b/scripts/check-memory-v2-stack.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { runMemoryV2AppCanaryCli } from './check-memory-v2-app-canary.mjs'; +import { runMemoryV2ContractCli } from './check-memory-v2-contracts.mjs'; +import { runMemoryV2HealthCli } from './check-memory-v2-health.mjs'; +import { runMemoryV2SessionFlowCli } from './check-memory-v2-session-flow.mjs'; +import { runMemoryV2ExternalSmokeCli } from './smoke-memory-v2-external.mjs'; +import { runMemoryPgvectorSmokeCli } from './smoke-memory-v2-pgvector.mjs'; +import { runMemoryQdrantSmokeCli } from './smoke-memory-v2-qdrant.mjs'; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:8081'; + +function loadEnvFile(filePath, env = process.env) { + if (!filePath || !fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!env[key]) env[key] = value; + } +} + +function normalizeBaseUrl(value) { + return String(value ?? '').trim().replace(/\/+$/, ''); +} + +function flag(value) { + const normalized = String(value ?? '').trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(normalized); +} + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +function parseJsonReport(text) { + try { + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} + +function check(name, ok, details = {}) { + return { name, ok: Boolean(ok), ...details }; +} + +function usage() { + return `Usage: + node scripts/check-memory-v2-stack.mjs [options] + +Options: + --base-url Portal base URL. Default: ${DEFAULT_BASE_URL}. + --env-file Env file to load before checks. Default: MEMIND_ENV_FILE or .env. + --no-env-file Do not load an env file. + --skip-app-canary Skip /api/runtime/status app canary. + --skip-session-flow Skip real user session flow verification. + --skip-backend-smokes Skip pgvector / external backend smoke probes. + --expect-backend Require memory.backend to match this value when app canary runs. + --expect-selected-backend Require memory.selectedBackend to match this value when app canary runs. + --timeout-ms Timeout passed to live checks. Default: 90000. + -h, --help Show this help. +`; +} + +export function parseMemoryV2StackArgs(argv = [], env = process.env) { + const options = { + baseUrl: normalizeBaseUrl(env.MEMORY_V2_STACK_BASE_URL || DEFAULT_BASE_URL), + envFile: env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'), + runAppCanary: true, + runSessionFlow: true, + runBackendSmokes: true, + expectedBackend: env.MEMORY_BACKEND || null, + expectedSelectedBackend: env.MEMORY_BACKEND || null, + timeoutMs: Number(env.MEMORY_V2_STACK_TIMEOUT_MS || 90000), + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base-url') { + options.baseUrl = normalizeBaseUrl(argv[++i]); + } else if (arg === '--env-file') { + options.envFile = argv[++i] ?? ''; + } else if (arg === '--no-env-file') { + options.envFile = ''; + } else if (arg === '--skip-app-canary') { + options.runAppCanary = false; + } else if (arg === '--skip-session-flow') { + options.runSessionFlow = false; + } else if (arg === '--skip-backend-smokes') { + options.runBackendSmokes = false; + } else if (arg === '--expect-backend') { + options.expectedBackend = argv[++i] ?? ''; + } else if (arg === '--expect-selected-backend') { + options.expectedSelectedBackend = argv[++i] ?? ''; + } else if (arg === '--timeout-ms') { + options.timeoutMs = Number(argv[++i]); + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.help && !options.baseUrl && (options.runAppCanary || options.runSessionFlow)) { + throw new Error('--base-url is required for live checks'); + } + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number'); + } + return options; +} + +function detectBackendPlans(env) { + return [ + { + name: 'pgvector', + configured: + flag(env.MEMORY_VECTOR_ENABLED) + && Boolean(String(env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim()) + && Boolean(String(env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? '').trim()), + reason: 'requires MEMORY_VECTOR_ENABLED, MEMORY_PGVECTOR_DATABASE_URL, MEMORY_PGVECTOR_EMBEDDING_MODULE', + }, + { + name: 'qdrant', + configured: + flag(env.MEMORY_QDRANT_ENABLED) + && Boolean(String(env.MEMORY_QDRANT_URL ?? '').trim()) + && Boolean(String(env.MEMORY_QDRANT_EMBEDDING_MODULE ?? '').trim()), + reason: 'requires MEMORY_QDRANT_ENABLED, MEMORY_QDRANT_URL, MEMORY_QDRANT_EMBEDDING_MODULE', + }, + { + name: 'weaviate', + configured: + flag(env.MEMORY_WEAVIATE_ENABLED) + && Boolean(String(env.MEMORY_WEAVIATE_URL ?? '').trim()) + && Boolean(String(env.MEMORY_WEAVIATE_EMBEDDING_MODULE ?? '').trim()), + reason: 'requires MEMORY_WEAVIATE_ENABLED, MEMORY_WEAVIATE_URL, MEMORY_WEAVIATE_EMBEDDING_MODULE', + }, + { + name: 'mem0', + configured: + flag(env.MEMORY_MEM0_ENABLED) + && Boolean(String(env.MEMORY_MEM0_API_KEY ?? '').trim()), + reason: 'requires MEMORY_MEM0_ENABLED and MEMORY_MEM0_API_KEY', + }, + { + name: 'letta', + configured: + flag(env.MEMORY_LETTA_ENABLED) + && Boolean(String(env.MEMORY_LETTA_API_KEY ?? '').trim()) + && Boolean(String(env.MEMORY_LETTA_AGENT_ID ?? '').trim()), + reason: 'requires MEMORY_LETTA_ENABLED, MEMORY_LETTA_API_KEY, MEMORY_LETTA_AGENT_ID', + }, + { + name: 'neo4j', + configured: + flag(env.MEMORY_NEO4J_ENABLED) + && Boolean(String(env.MEMORY_NEO4J_HTTP_URL ?? env.MEMORY_NEO4J_URI ?? '').trim()) + && Boolean(String(env.MEMORY_NEO4J_USER ?? '').trim()) + && Boolean(String(env.MEMORY_NEO4J_PASSWORD ?? '').trim()), + reason: 'requires MEMORY_NEO4J_ENABLED, MEMORY_NEO4J_HTTP_URL or MEMORY_NEO4J_URI, MEMORY_NEO4J_USER, MEMORY_NEO4J_PASSWORD', + }, + { + name: 'redis-streams', + configured: + flag(env.MEMORY_REDIS_STREAMS_ENABLED) + && Boolean(String(env.MEMORY_REDIS_STREAMS_URL ?? '').trim()), + reason: 'requires MEMORY_REDIS_STREAMS_ENABLED and MEMORY_REDIS_STREAMS_URL', + }, + { + name: 'langgraph', + configured: + flag(env.MEMORY_LANGGRAPH_ENABLED) + && Boolean(String(env.MEMORY_LANGGRAPH_URL ?? '').trim()), + reason: 'requires MEMORY_LANGGRAPH_ENABLED and MEMORY_LANGGRAPH_URL', + }, + ]; +} + +async function runCliAndParse(executor) { + const stdout = writableBuffer(); + const code = await executor(stdout); + const report = parseJsonReport(stdout.value()); + return { code, report }; +} + +export async function runMemoryV2StackCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, + stderr = process.stderr, + runners = {}, +} = {}) { + const options = parseMemoryV2StackArgs(argv, env); + if (options.help) { + stdout.write(usage()); + return 0; + } + if (options.envFile) loadEnvFile(options.envFile, env); + + const checks = []; + const sections = {}; + + const logProgress = (message) => { + stderr.write(`[memory-v2-stack] ${message}\n`); + }; + + logProgress('running contracts'); + const contractResult = await runCliAndParse((buffer) => + (runners.runContractCli ?? runMemoryV2ContractCli)({ argv: [], stdout: buffer }), + ); + sections.contracts = contractResult.report; + checks.push(check('contracts', contractResult.code === 0, { + backendCount: contractResult.report?.summary?.backendCount ?? null, + })); + logProgress(`contracts ${contractResult.code === 0 ? 'ok' : 'failed'}`); + + const healthArgs = ['--require-enabled']; + if (options.expectedBackend) healthArgs.push('--expect-backend', options.expectedBackend); + if (!options.envFile) healthArgs.push('--no-env-file'); + else healthArgs.push('--env-file', options.envFile); + logProgress('running health'); + const healthResult = await runCliAndParse((buffer) => + (runners.runHealthCli ?? runMemoryV2HealthCli)({ argv: healthArgs, env, stdout: buffer }), + ); + sections.health = healthResult.report; + checks.push(check('health', healthResult.code === 0, { + backend: healthResult.report?.summary?.backend ?? null, + selectedBackend: healthResult.report?.summary?.selectedBackend ?? null, + })); + logProgress(`health ${healthResult.code === 0 ? 'ok' : 'failed'}`); + + if (options.runAppCanary) { + const appArgs = ['--base-url', options.baseUrl, '--require-enabled', '--require-target-healthy', '--timeout-ms', String(options.timeoutMs)]; + if (options.expectedBackend) appArgs.push('--expect-backend', options.expectedBackend); + if (options.expectedSelectedBackend) { + appArgs.push('--expect-selected-backend', options.expectedSelectedBackend); + } + logProgress('running app canary'); + const appResult = await runCliAndParse((buffer) => + (runners.runAppCanaryCli ?? runMemoryV2AppCanaryCli)({ argv: appArgs, env, stdout: buffer }), + ); + sections.appCanary = appResult.report; + checks.push(check('app_canary', appResult.code === 0, { + baseUrl: options.baseUrl, + })); + logProgress(`app canary ${appResult.code === 0 ? 'ok' : 'failed'}`); + } + + if (options.runSessionFlow) { + const sessionArgs = ['--base-url', options.baseUrl, '--timeout-ms', String(options.timeoutMs)]; + if (options.expectedBackend) sessionArgs.push('--expect-backend', options.expectedBackend); + if (options.expectedSelectedBackend) { + sessionArgs.push('--expect-selected-backend', options.expectedSelectedBackend); + } + logProgress('running session flow'); + const sessionResult = await runCliAndParse((buffer) => + (runners.runSessionFlowCli ?? runMemoryV2SessionFlowCli)({ argv: sessionArgs, env, stdout: buffer }), + ); + sections.sessionFlow = sessionResult.report; + checks.push(check('session_flow', sessionResult.code === 0, { + sessionId: sessionResult.report?.sessionId ?? null, + })); + logProgress(`session flow ${sessionResult.code === 0 ? 'ok' : 'failed'}`); + } + + const backendPlans = detectBackendPlans(env); + const backendReports = []; + if (options.runBackendSmokes) { + for (const plan of backendPlans) { + if (!plan.configured) { + backendReports.push({ + backend: plan.name, + skipped: true, + reason: 'not_configured', + expectedConfig: plan.reason, + }); + checks.push(check(`backend_${plan.name}`, true, { skipped: true })); + continue; + } + + if (plan.name === 'pgvector') { + logProgress('running backend smoke: pgvector'); + const runner = runners.runPgvectorSmokeCli ?? runMemoryPgvectorSmokeCli; + const report = await runner( + [], + env, + { stdout: writableBuffer() }, + ).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); + backendReports.push({ backend: plan.name, report }); + checks.push(check(`backend_${plan.name}`, report?.ok === true, { + error: report?.error ?? null, + })); + logProgress(`backend smoke pgvector ${report?.ok === true ? 'ok' : 'failed'}`); + continue; + } + + if (plan.name === 'qdrant') { + logProgress('running backend smoke: qdrant'); + const rawRunner = runners.runQdrantSmokeCli ?? runMemoryQdrantSmokeCli; + const runtimeRunner = runners.runExternalSmokeCli ?? runMemoryV2ExternalSmokeCli; + const rawReport = await rawRunner({ + env, + stdout: writableBuffer(), + }).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); + const runtimeReport = await runtimeRunner({ + argv: ['--backend', 'qdrant'], + env, + stdout: writableBuffer(), + }).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); + backendReports.push({ backend: plan.name, rawReport, runtimeReport }); + checks.push(check(`backend_${plan.name}`, rawReport?.ok === true && runtimeReport?.ok === true, { + rawError: rawReport?.error ?? null, + runtimeError: runtimeReport?.error ?? null, + })); + logProgress(`backend smoke qdrant ${(rawReport?.ok === true && runtimeReport?.ok === true) ? 'ok' : 'failed'}`); + continue; + } + + logProgress(`running backend smoke: ${plan.name}`); + const runtimeRunner = runners.runExternalSmokeCli ?? runMemoryV2ExternalSmokeCli; + const runtimeReport = await runtimeRunner({ + argv: ['--backend', plan.name], + env, + stdout: writableBuffer(), + }).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })); + backendReports.push({ backend: plan.name, runtimeReport }); + checks.push(check(`backend_${plan.name}`, runtimeReport?.ok === true, { + error: runtimeReport?.error ?? null, + })); + logProgress(`backend smoke ${plan.name} ${runtimeReport?.ok === true ? 'ok' : 'failed'}`); + } + } + sections.backends = backendReports; + + const report = { + ok: checks.every((item) => item.ok), + checkedAt: new Date().toISOString(), + summary: { + baseUrl: options.baseUrl, + expectedBackend: options.expectedBackend ?? null, + expectedSelectedBackend: options.expectedSelectedBackend ?? null, + configuredBackends: backendPlans.filter((item) => item.configured).map((item) => item.name), + skippedBackends: backendReports.filter((item) => item.skipped).map((item) => item.backend), + }, + checks, + sections, + }; + + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2StackCli().then((code) => { + process.exitCode = code; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/check-memory-v2-stack.test.mjs b/scripts/check-memory-v2-stack.test.mjs new file mode 100644 index 0000000..999bf81 --- /dev/null +++ b/scripts/check-memory-v2-stack.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { parseMemoryV2StackArgs, runMemoryV2StackCli } from './check-memory-v2-stack.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2StackArgs maps aggregate options', () => { + assert.deepEqual( + parseMemoryV2StackArgs([ + '--base-url', + 'http://127.0.0.1:18081/', + '--skip-session-flow', + '--skip-backend-smokes', + '--expect-backend', + 'pgvector', + '--expect-selected-backend', + 'pgvector', + '--timeout-ms', + '5000', + ], {}), + { + baseUrl: 'http://127.0.0.1:18081', + envFile: path.join(process.cwd(), '.env'), + runAppCanary: true, + runSessionFlow: false, + runBackendSmokes: false, + expectedBackend: 'pgvector', + expectedSelectedBackend: 'pgvector', + timeoutMs: 5000, + help: false, + }, + ); +}); + +test('runMemoryV2StackCli aggregates configured and skipped backend checks', async () => { + const stdout = writableBuffer(); + const code = await runMemoryV2StackCli({ + argv: ['--skip-app-canary', '--skip-session-flow'], + env: { + MEMORY_BACKEND: 'pgvector', + MEMORY_VECTOR_ENABLED: '1', + MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://db', + MEMORY_PGVECTOR_EMBEDDING_MODULE: './embed.mjs', + MEMORY_QDRANT_ENABLED: '1', + MEMORY_QDRANT_URL: 'http://qdrant.local', + MEMORY_QDRANT_EMBEDDING_MODULE: './embed.mjs', + }, + stdout, + runners: { + async runContractCli({ stdout: buffer }) { + buffer.write(JSON.stringify({ ok: true, summary: { backendCount: 9 } })); + return 0; + }, + async runHealthCli({ stdout: buffer }) { + buffer.write(JSON.stringify({ ok: true, summary: { backend: 'pgvector', selectedBackend: 'pgvector' } })); + return 0; + }, + async runPgvectorSmokeCli() { + return { ok: true, source: 'pgvector-smoke' }; + }, + async runQdrantSmokeCli() { + return { ok: true, source: 'qdrant-raw' }; + }, + async runExternalSmokeCli({ argv: cliArgv }) { + return { ok: true, backend: cliArgv[1], source: 'runtime-smoke' }; + }, + }, + }); + const report = JSON.parse(stdout.value()); + + assert.equal(code, 0); + assert.equal(report.ok, true); + assert.deepEqual(report.summary.configuredBackends, ['pgvector', 'qdrant']); + assert.ok(report.summary.skippedBackends.includes('weaviate')); + assert.equal(report.checks.find((item) => item.name === 'contracts').ok, true); + assert.equal(report.checks.find((item) => item.name === 'health').ok, true); + assert.equal(report.checks.find((item) => item.name === 'backend_pgvector').ok, true); + assert.equal(report.checks.find((item) => item.name === 'backend_qdrant').ok, true); +}); diff --git a/scripts/embed-memory-v2-local-hash.mjs b/scripts/embed-memory-v2-local-hash.mjs new file mode 100644 index 0000000..d79de56 --- /dev/null +++ b/scripts/embed-memory-v2-local-hash.mjs @@ -0,0 +1,49 @@ +const DEFAULT_DIMENSIONS = 3; + +function resolveDimensions(value) { + const dimensions = Number(value ?? process.env.MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS ?? DEFAULT_DIMENSIONS); + if (!Number.isInteger(dimensions) || dimensions < 1 || dimensions > 4096) { + throw new Error(`Invalid MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS: ${value}`); + } + return dimensions; +} + +function hashToken(token) { + let hash = 2166136261; + for (const char of String(token)) { + hash ^= char.codePointAt(0); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function tokenize(text) { + return String(text ?? '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); +} + +export function embedText(text, { dimensions = undefined } = {}) { + const resolvedDimensions = resolveDimensions(dimensions); + const vector = Array.from({ length: resolvedDimensions }, () => 0); + const tokens = tokenize(text); + for (const token of tokens.length ? tokens : ['empty']) { + const hash = hashToken(token); + const index = hash % resolvedDimensions; + const sign = hash & 1 ? 1 : -1; + vector[index] += sign * (1 + (token.length % 7)); + } + const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1; + return vector.map((value) => Number((value / norm).toFixed(8))); +} + +export async function embedQuery(query, input = {}) { + return embedText(query, { + dimensions: input.dimensions ?? input.embeddingDimensions, + }); +} + +export default embedQuery; diff --git a/scripts/embed-memory-v2-local-hash.test.mjs b/scripts/embed-memory-v2-local-hash.test.mjs new file mode 100644 index 0000000..ea964cf --- /dev/null +++ b/scripts/embed-memory-v2-local-hash.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { embedQuery, embedText } from './embed-memory-v2-local-hash.mjs'; + +test('embedText returns deterministic normalized vectors', () => { + const first = embedText('memory chain local smoke', { dimensions: 8 }); + const second = embedText('memory chain local smoke', { dimensions: 8 }); + const norm = Math.sqrt(first.reduce((sum, value) => sum + value * value, 0)); + + assert.deepEqual(first, second); + assert.equal(first.length, 8); + assert.ok(Math.abs(norm - 1) < 0.000001); +}); + +test('embedQuery accepts dimensions from runtime input', async () => { + const vector = await embedQuery('pgvector canary', { dimensions: 3 }); + + assert.equal(vector.length, 3); + assert.equal(vector.every((item) => Number.isFinite(item)), true); +}); + +test('embedText validates dimensions', () => { + assert.throws( + () => embedText('bad', { dimensions: 0 }), + /Invalid MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS/, + ); +}); diff --git a/scripts/mock-memory-v2-services.mjs b/scripts/mock-memory-v2-services.mjs new file mode 100644 index 0000000..d6967e1 --- /dev/null +++ b/scripts/mock-memory-v2-services.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import http from 'node:http'; +import { URL } from 'node:url'; + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + if (!body.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(body)); + } catch (err) { + reject(err); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); +} + +export function createMockMemoryV2Service() { + const state = { + mem0: { + writes: [], + compacts: [], + }, + letta: { + messages: [], + compacts: [], + }, + langgraph: { + resolves: [], + }, + }; + + function reset() { + state.mem0.writes.length = 0; + state.mem0.compacts.length = 0; + state.letta.messages.length = 0; + state.letta.compacts.length = 0; + state.langgraph.resolves.length = 0; + } + + async function handle(req, res) { + const url = new URL(req.url, 'http://127.0.0.1'); + const pathname = url.pathname; + + if (req.method === 'GET' && pathname === '/health') { + sendJson(res, 200, { ok: true, service: 'mock-memory-v2-services' }); + return; + } + + if (req.method === 'POST' && pathname === '/__admin/reset') { + reset(); + sendJson(res, 200, { ok: true }); + return; + } + + if (req.method === 'GET' && pathname === '/__admin/state') { + sendJson(res, 200, { + ok: true, + state, + }); + return; + } + + if (req.method === 'POST' && pathname === '/mem0/v1/memories') { + const body = await readJson(req); + state.mem0.writes.push(body); + sendJson(res, 200, { + saved: 1, + analyzed: 1, + memory_count: Array.isArray(body.messages) ? body.messages.length : 0, + }); + return; + } + + if (req.method === 'POST' && pathname === '/mem0/v1/memories/compact') { + const body = await readJson(req); + state.mem0.compacts.push(body); + sendJson(res, 200, { + analyzed: 1, + memory_count: state.mem0.writes.filter((item) => item.user_id === body.user_id).length, + }); + return; + } + + if (req.method === 'POST' && /^\/letta\/v1\/agents\/[^/]+\/messages$/.test(pathname)) { + const body = await readJson(req); + const agentId = pathname.split('/')[4]; + state.letta.messages.push({ agentId, ...body }); + sendJson(res, 200, { + saved: 1, + analyzed: 0, + memory_count: Array.isArray(body.messages) ? body.messages.length : 0, + }); + return; + } + + if (req.method === 'POST' && /^\/letta\/v1\/agents\/[^/]+\/memory\/compact$/.test(pathname)) { + const body = await readJson(req); + const agentId = pathname.split('/')[4]; + state.letta.compacts.push({ agentId, ...body }); + sendJson(res, 200, { + analyzed: 1, + memory_count: state.letta.messages.filter((item) => item.user_id === body.user_id).length, + }); + return; + } + + if (req.method === 'POST' && /^\/letta\/v1\/agents\/[^/]+\/memory$/.test(pathname)) { + const body = await readJson(req); + const agentId = pathname.split('/')[4]; + const messages = state.letta.messages + .filter((item) => item.agentId === agentId && item.user_id === body.user_id) + .flatMap((item) => Array.isArray(item.messages) ? item.messages : []) + .map((item, index) => ({ + id: `letta-${index + 1}`, + label: 'lifecycle', + text: String(item.text ?? item.content ?? '').trim() || `letta-memory-${index + 1}`, + })) + .filter((item) => item.text); + sendJson(res, 200, { + memories: messages.length ? messages : ['letta lifecycle memory'], + activeGoals: ['memory-v2'], + }); + return; + } + + if (req.method === 'POST' && pathname === '/langgraph/memory/resolve') { + const body = await readJson(req); + state.langgraph.resolves.push(body); + sendJson(res, 200, { + memories: [{ + id: 'langgraph-1', + label: 'policy', + text: `langgraph route for ${body.user_id ?? 'unknown-user'}`, + }], + activeGoals: ['route-memory'], + behaviorSummary: null, + }); + return; + } + + sendJson(res, 404, { ok: false, message: 'not found' }); + } + + return { + state, + reset, + createServer() { + return http.createServer((req, res) => { + Promise.resolve(handle(req, res)).catch((err) => { + sendJson(res, 500, { + ok: false, + message: err instanceof Error ? err.message : String(err), + }); + }); + }); + }, + }; +} + +export async function startMockMemoryV2Service({ + port = Number(process.env.MEMORY_V2_MOCK_PORT ?? 19400) || 19400, + host = process.env.MEMORY_V2_MOCK_HOST ?? '127.0.0.1', + stdout = process.stdout, +} = {}) { + const service = createMockMemoryV2Service(); + const server = service.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, resolve); + }); + stdout.write(`${JSON.stringify({ ok: true, host, port }, null, 2)}\n`); + return { service, server, host, port }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + startMockMemoryV2Service().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/mock-memory-v2-services.test.mjs b/scripts/mock-memory-v2-services.test.mjs new file mode 100644 index 0000000..05b6805 --- /dev/null +++ b/scripts/mock-memory-v2-services.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { once } from 'node:events'; +import { createMockMemoryV2Service } from './mock-memory-v2-services.mjs'; + +test('mock memory v2 service stores mem0, letta, and langgraph requests', async () => { + const service = createMockMemoryV2Service(); + const server = service.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + const baseUrl = `http://127.0.0.1:${port}`; + + await fetch(`${baseUrl}/mem0/v1/memories`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ user_id: 'u1', messages: [{ text: 'hello mem0' }] }), + }); + await fetch(`${baseUrl}/letta/v1/agents/agent_1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ user_id: 'u1', messages: [{ text: 'hello letta' }] }), + }); + const langgraphResponse = await fetch(`${baseUrl}/langgraph/memory/resolve`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ user_id: 'u1', query: 'route me' }), + }); + const langgraphPayload = await langgraphResponse.json(); + const stateResponse = await fetch(`${baseUrl}/__admin/state`); + const statePayload = await stateResponse.json(); + + assert.equal(statePayload.state.mem0.writes.length, 1); + assert.equal(statePayload.state.letta.messages.length, 1); + assert.equal(statePayload.state.langgraph.resolves.length, 1); + assert.deepEqual(langgraphPayload.activeGoals, ['route-memory']); + + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +}); diff --git a/scripts/scaffold-memory-v2-backend.mjs b/scripts/scaffold-memory-v2-backend.mjs new file mode 100644 index 0000000..8a61b70 --- /dev/null +++ b/scripts/scaffold-memory-v2-backend.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + normalizeMemoryV2AdapterScaffoldOptions, + renderMemoryV2AdapterScaffold, +} from '../memory-v2-adapter-scaffold.mjs'; + +function usage() { + return `Usage: + node scripts/scaffold-memory-v2-backend.mjs --name --category --capability [options] + +Options: + --name Backend name, normalized to kebab-case. + --category semantic | extraction | lifecycle | behavior | policy. + --role Backend role. Default: optional-plugin. + --capability resolve | write | compact. Repeatable. + --flag Feature flag. Default: MEMORY__ENABLED. + --out Output path. Default: memory-v2-.mjs. + --write Write file. Default is dry-run to stdout. + -h, --help Show this help. +`; +} + +export function parseMemoryV2ScaffoldArgs(argv = []) { + const options = { + name: null, + category: null, + role: 'optional-plugin', + capabilities: [], + flag: null, + out: null, + write: false, + help: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--name') { + options.name = argv[++i] ?? ''; + } else if (arg === '--category') { + options.category = argv[++i] ?? ''; + } else if (arg === '--role') { + options.role = argv[++i] ?? ''; + } else if (arg === '--capability') { + options.capabilities.push(argv[++i] ?? ''); + } else if (arg === '--flag') { + options.flag = argv[++i] ?? ''; + } else if (arg === '--out') { + options.out = argv[++i] ?? ''; + } else if (arg === '--write') { + options.write = true; + } else if (arg === '-h' || arg === '--help') { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +export async function runMemoryV2ScaffoldCli({ + argv = process.argv.slice(2), + stdout = process.stdout, + cwd = process.cwd(), +} = {}) { + const parsed = parseMemoryV2ScaffoldArgs(argv); + if (parsed.help) { + stdout.write(usage()); + return { ok: true, mode: 'help' }; + } + const normalized = normalizeMemoryV2AdapterScaffoldOptions(parsed); + const source = renderMemoryV2AdapterScaffold(normalized); + const outPath = parsed.out || `memory-v2-${normalized.name}.mjs`; + + if (!parsed.write) { + stdout.write(source); + return { ok: true, mode: 'dry-run', outPath }; + } + + const absoluteOut = path.resolve(cwd, outPath); + await fs.mkdir(path.dirname(absoluteOut), { recursive: true }); + try { + await fs.writeFile(absoluteOut, source, { encoding: 'utf8', flag: 'wx' }); + } catch (err) { + if (err?.code === 'EEXIST') { + throw new Error(`Refusing to overwrite existing adapter: ${absoluteOut}`); + } + throw err; + } + stdout.write(`Memory V2 backend scaffold created: ${absoluteOut}\n`); + return { ok: true, mode: 'write', outPath: absoluteOut }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2ScaffoldCli().then((result) => { + process.exitCode = result.ok ? 0 : 1; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/scaffold-memory-v2-backend.test.mjs b/scripts/scaffold-memory-v2-backend.test.mjs new file mode 100644 index 0000000..90b2e96 --- /dev/null +++ b/scripts/scaffold-memory-v2-backend.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + parseMemoryV2ScaffoldArgs, + runMemoryV2ScaffoldCli, +} from './scaffold-memory-v2-backend.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2ScaffoldArgs parses scaffold options', () => { + assert.deepEqual( + parseMemoryV2ScaffoldArgs([ + '--name', + 'qdrant', + '--category', + 'semantic', + '--role', + 'scale-out', + '--capability', + 'resolve', + '--flag', + 'MEMORY_QDRANT_ENABLED', + '--out', + 'memory-v2-qdrant.mjs', + '--write', + ]), + { + name: 'qdrant', + category: 'semantic', + role: 'scale-out', + capabilities: ['resolve'], + flag: 'MEMORY_QDRANT_ENABLED', + out: 'memory-v2-qdrant.mjs', + write: true, + help: false, + }, + ); +}); + +test('runMemoryV2ScaffoldCli dry-run prints adapter source', async () => { + const stdout = writableBuffer(); + const result = await runMemoryV2ScaffoldCli({ + argv: [ + '--name', + 'qdrant', + '--category', + 'semantic', + '--capability', + 'resolve', + ], + stdout, + }); + + assert.equal(result.mode, 'dry-run'); + assert.match(stdout.value(), /export function createQdrantMemoryBackend/); + assert.match(stdout.value(), /name: 'qdrant'/); +}); + +test('runMemoryV2ScaffoldCli writes new adapter and refuses overwrite', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-v2-scaffold-')); + const stdout = writableBuffer(); + const argv = [ + '--name', + 'mem0', + '--category', + 'extraction', + '--capability', + 'write', + '--capability', + 'compact', + '--write', + ]; + + const result = await runMemoryV2ScaffoldCli({ argv, stdout, cwd: tmpDir }); + const written = await fs.readFile(path.join(tmpDir, 'memory-v2-mem0.mjs'), 'utf8'); + + assert.equal(result.mode, 'write'); + assert.match(stdout.value(), /Memory V2 backend scaffold created/); + assert.match(written, /export function createMem0MemoryBackend/); + await assert.rejects( + () => runMemoryV2ScaffoldCli({ argv, stdout: writableBuffer(), cwd: tmpDir }), + /Refusing to overwrite/, + ); +}); diff --git a/scripts/setup-memory-v2-pgvector-schema.mjs b/scripts/setup-memory-v2-pgvector-schema.mjs new file mode 100644 index 0000000..b47ca9e --- /dev/null +++ b/scripts/setup-memory-v2-pgvector-schema.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { + buildPgvectorMemorySchemaSql, + ensurePgvectorMemorySchema, +} from '../memory-v2-pgvector-schema.mjs'; + +const DEFAULT_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL'; + +function usage() { + return ` +Usage: + node scripts/setup-memory-v2-pgvector-schema.mjs [options] + +Default mode is dry-run: print SQL only. + +Options: + --apply Execute SQL against PostgreSQL. + --table Table name. Default: memory_embeddings. + --dimensions Embedding dimensions. Default: 1536. + --create-extension Include CREATE EXTENSION IF NOT EXISTS vector. + --create-vector-index Include ivfflat vector index creation. + --url-env Env var containing PostgreSQL URL. Default: ${DEFAULT_URL_ENV}. + -h, --help Show this help. +`.trim(); +} + +export function parseMemoryPgvectorSchemaArgs(argv = []) { + const options = { + apply: false, + tableName: 'memory_embeddings', + dimensions: 1536, + createExtension: false, + createVectorIndex: false, + urlEnv: DEFAULT_URL_ENV, + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case '--apply': + options.apply = true; + break; + case '--create-extension': + options.createExtension = true; + break; + case '--create-vector-index': + options.createVectorIndex = true; + break; + case '--table': + i += 1; + options.tableName = argv[i]; + break; + case '--dimensions': + i += 1; + options.dimensions = Number(argv[i]); + break; + case '--url-env': + i += 1; + options.urlEnv = argv[i]; + break; + case '-h': + case '--help': + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.tableName) throw new Error('--table requires a value'); + if (!Number.isFinite(options.dimensions)) throw new Error('--dimensions requires a number'); + if (!options.urlEnv) throw new Error('--url-env requires a value'); + return options; +} + +function printSql(statements) { + for (const statement of statements) { + console.log(`${statement};\n`); + } +} + +export async function runMemoryPgvectorSchemaCli(argv = process.argv.slice(2), env = process.env) { + const options = parseMemoryPgvectorSchemaArgs(argv); + if (options.help) { + console.log(usage()); + return { ok: true, mode: 'help' }; + } + + const schemaOptions = { + tableName: options.tableName, + dimensions: options.dimensions, + createExtension: options.createExtension, + createVectorIndex: options.createVectorIndex, + }; + const statements = buildPgvectorMemorySchemaSql(schemaOptions); + + if (!options.apply) { + console.log('-- Memory V2 pgvector schema dry-run'); + console.log('-- No database changes were made. Re-run with --apply to execute.'); + printSql(statements); + return { ok: true, mode: 'dry-run', statements: statements.length }; + } + + const connectionString = env[options.urlEnv]; + if (!connectionString) { + throw new Error(`--apply requires ${options.urlEnv} to be set`); + } + const { default: pg } = await import('pg'); + const pool = new pg.Pool({ connectionString, max: 1 }); + try { + const result = await ensurePgvectorMemorySchema(pool, schemaOptions); + console.log( + `Memory V2 pgvector schema ensured: table=${result.tableName}, dimensions=${result.dimensions}, statements=${result.statements}`, + ); + return { ok: true, mode: 'apply', ...result }; + } finally { + await pool.end(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryPgvectorSchemaCli().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/setup-memory-v2-pgvector-schema.test.mjs b/scripts/setup-memory-v2-pgvector-schema.test.mjs new file mode 100644 index 0000000..db935bd --- /dev/null +++ b/scripts/setup-memory-v2-pgvector-schema.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryPgvectorSchemaArgs, + runMemoryPgvectorSchemaCli, +} from './setup-memory-v2-pgvector-schema.mjs'; + +test('parseMemoryPgvectorSchemaArgs defaults to dry-run safe settings', () => { + assert.deepEqual(parseMemoryPgvectorSchemaArgs([]), { + apply: false, + tableName: 'memory_embeddings', + dimensions: 1536, + createExtension: false, + createVectorIndex: false, + urlEnv: 'MEMORY_PGVECTOR_DATABASE_URL', + help: false, + }); +}); + +test('parseMemoryPgvectorSchemaArgs maps explicit schema options', () => { + assert.deepEqual( + parseMemoryPgvectorSchemaArgs([ + '--apply', + '--table', + 'memory_embeddings_local', + '--dimensions', + '768', + '--create-extension', + '--create-vector-index', + '--url-env', + 'LOCAL_MEMORY_PG_URL', + ]), + { + apply: true, + tableName: 'memory_embeddings_local', + dimensions: 768, + createExtension: true, + createVectorIndex: true, + urlEnv: 'LOCAL_MEMORY_PG_URL', + help: false, + }, + ); +}); + +test('runMemoryPgvectorSchemaCli dry-run prints SQL without requiring database url', async () => { + const lines = []; + const originalLog = console.log; + console.log = (line = '') => { + lines.push(String(line)); + }; + try { + const result = await runMemoryPgvectorSchemaCli([ + '--table', + 'memory_embeddings', + '--dimensions', + '1536', + ], {}); + assert.deepEqual(result, { ok: true, mode: 'dry-run', statements: 3 }); + assert.match(lines.join('\n'), /No database changes were made/); + assert.match(lines.join('\n'), /CREATE TABLE IF NOT EXISTS "memory_embeddings"/); + } finally { + console.log = originalLog; + } +}); + +test('runMemoryPgvectorSchemaCli apply requires explicit PostgreSQL url env', async () => { + await assert.rejects( + () => runMemoryPgvectorSchemaCli(['--apply'], {}), + /MEMORY_PGVECTOR_DATABASE_URL/, + ); +}); diff --git a/scripts/smoke-memory-v2-external.mjs b/scripts/smoke-memory-v2-external.mjs new file mode 100644 index 0000000..5fa792b --- /dev/null +++ b/scripts/smoke-memory-v2-external.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import { createMemoryV2Runtime } from '../memory-v2-runtime.mjs'; + +const BACKENDS = new Set([ + 'qdrant', + 'weaviate', + 'mem0', + 'letta', + 'neo4j', + 'redis-streams', + 'langgraph', +]); + +function usage() { + return ` +Usage: + node scripts/smoke-memory-v2-external.mjs --backend [options] + +Runs a Memory V2 external backend smoke through the real runtime wiring. + +Options: + --backend qdrant | weaviate | mem0 | letta | neo4j | redis-streams | langgraph + --operation resolve | write | compact. Default resolves per backend. + --query Resolve query. Default: memory-chain smoke + --limit Resolve limit. Default: 1 + --vector Optional query embedding CSV for resolve. + --user-id Smoke user id. Default: memory-v2-smoke-user + --session-id Smoke session id. Default: memory-v2-smoke-session + -h, --help Show this help. +`.trim(); +} + +function parseVector(value) { + if (value == null || value === '') return null; + const vector = String(value).split(',').map((item) => Number(item.trim())); + if (!vector.length || vector.some((item) => !Number.isFinite(item))) { + throw new Error('--vector must be a comma-separated list of numbers'); + } + return vector; +} + +function defaultOperation(backend) { + if (backend === 'mem0' || backend === 'redis-streams') return 'write'; + return 'resolve'; +} + +export function parseMemoryV2ExternalSmokeArgs(argv = []) { + const options = { + backend: null, + operation: null, + query: 'memory-chain smoke', + limit: 1, + vector: null, + userId: 'memory-v2-smoke-user', + sessionId: 'memory-v2-smoke-session', + help: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case '--backend': + options.backend = argv[++i] ?? ''; + break; + case '--operation': + options.operation = argv[++i] ?? ''; + break; + case '--query': + options.query = argv[++i] ?? ''; + break; + case '--limit': + options.limit = Number(argv[++i]); + break; + case '--vector': + options.vector = parseVector(argv[++i]); + break; + case '--user-id': + options.userId = argv[++i] ?? ''; + break; + case '--session-id': + options.sessionId = argv[++i] ?? ''; + break; + case '-h': + case '--help': + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + if (options.help) return options; + if (!BACKENDS.has(options.backend)) { + throw new Error(`--backend must be one of: ${[...BACKENDS].join(', ')}`); + } + options.operation ||= defaultOperation(options.backend); + if (!['resolve', 'write', 'compact'].includes(options.operation)) { + throw new Error('--operation must be resolve, write, or compact'); + } + if (!options.query) throw new Error('--query requires a value'); + if (!Number.isInteger(options.limit) || options.limit < 1) { + throw new Error('--limit requires a positive integer'); + } + if (!options.userId) throw new Error('--user-id requires a value'); + if (!options.sessionId) throw new Error('--session-id requires a value'); + return options; +} + +function createSyntheticLegacyMemoryService() { + return { + async listMemories() { + return []; + }, + async saveAndAnalyze() { + return { saved: 0, analyzed: 0, memories: 0 }; + }, + async analyzeUser() { + return { analyzed: 0, memories: 0 }; + }, + }; +} + +async function runOperation(memory, options) { + const input = { + userId: options.userId, + sessionId: options.sessionId, + query: options.query, + embedding: options.vector, + limit: options.limit, + messages: [{ + role: 'user', + text: options.query, + content: options.query, + }], + eventType: 'memory-v2.smoke', + }; + if (options.operation === 'resolve') return memory.resolve(input); + if (options.operation === 'write') return memory.write(input); + return memory.compact(input); +} + +export async function runMemoryV2ExternalSmokeCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, + fetchImpl = globalThis.fetch, + importRedis = (specifier) => import(specifier), + importModule = (specifier) => import(specifier), +} = {}) { + const options = parseMemoryV2ExternalSmokeArgs(argv); + if (options.help) { + stdout.write(`${usage()}\n`); + return { ok: true, mode: 'help' }; + } + const memory = await createMemoryV2Runtime({ + legacyMemoryService: createSyntheticLegacyMemoryService(), + env: { + ...env, + MEMORY_ENABLED: '1', + MEMORY_BACKEND: options.backend, + }, + logger: console, + fetchImpl, + importRedis, + importModule, + }); + try { + const result = await runOperation(memory, options); + const backend = memory.getStatus().backends.find((item) => item.name === options.backend); + const report = { + ok: result?.ok !== false, + checkedAt: new Date().toISOString(), + backend: options.backend, + operation: options.operation, + selectedBackend: memory.getStatus().selectedBackend, + backendAvailable: backend?.available ?? false, + backendReason: backend?.reason ?? null, + result, + }; + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report; + } finally { + await memory.close?.(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryV2ExternalSmokeCli().then((result) => { + process.exitCode = result.ok ? 0 : 1; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/smoke-memory-v2-external.test.mjs b/scripts/smoke-memory-v2-external.test.mjs new file mode 100644 index 0000000..a669f68 --- /dev/null +++ b/scripts/smoke-memory-v2-external.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryV2ExternalSmokeArgs, + runMemoryV2ExternalSmokeCli, +} from './smoke-memory-v2-external.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryV2ExternalSmokeArgs validates backend and defaults operation', () => { + assert.deepEqual( + parseMemoryV2ExternalSmokeArgs(['--backend', 'redis-streams']), + { + backend: 'redis-streams', + operation: 'write', + query: 'memory-chain smoke', + limit: 1, + vector: null, + userId: 'memory-v2-smoke-user', + sessionId: 'memory-v2-smoke-session', + help: false, + }, + ); + assert.deepEqual( + parseMemoryV2ExternalSmokeArgs([ + '--backend', + 'weaviate', + '--operation', + 'resolve', + '--vector', + '0.1,0.2', + '--limit', + '2', + ]).vector, + [0.1, 0.2], + ); + assert.throws( + () => parseMemoryV2ExternalSmokeArgs(['--backend', 'unknown']), + /--backend must be one of/, + ); +}); + +test('runMemoryV2ExternalSmokeCli smokes Qdrant through runtime HTTP wiring', async () => { + const stdout = writableBuffer(); + const report = await runMemoryV2ExternalSmokeCli({ + argv: ['--backend', 'qdrant', '--operation', 'resolve', '--limit', '2'], + env: { + MEMORY_QDRANT_ENABLED: '1', + MEMORY_QDRANT_URL: 'http://qdrant.local', + MEMORY_QDRANT_COLLECTION: 'memind_memory', + MEMORY_QDRANT_EMBEDDING_MODULE: './embed.mjs', + }, + stdout, + async importModule() { + return { async embedQuery() { return [0.1, 0.2, 0.3]; } }; + }, + async fetchImpl(url, options) { + assert.equal(url, 'http://qdrant.local/collections/memind_memory/points/search'); + assert.equal(JSON.parse(options.body).limit, 2); + return { + ok: true, + async json() { + return { + result: [ + { + id: 'q1', + score: 0.9, + payload: { content: 'semantic memory', type: 'semantic' }, + }, + ], + }; + }, + }; + }, + }); + const output = JSON.parse(stdout.value()); + + assert.equal(report.backendAvailable, true); + assert.equal(output.selectedBackend, 'qdrant'); + assert.equal(output.result.source, 'qdrant'); +}); + +test('runMemoryV2ExternalSmokeCli smokes Weaviate through runtime HTTP wiring', async () => { + const stdout = writableBuffer(); + const report = await runMemoryV2ExternalSmokeCli({ + argv: ['--backend', 'weaviate', '--operation', 'resolve', '--limit', '2'], + env: { + MEMORY_WEAVIATE_ENABLED: '1', + MEMORY_WEAVIATE_URL: 'https://weaviate.local', + MEMORY_WEAVIATE_EMBEDDING_MODULE: './embed.mjs', + }, + stdout, + async importModule() { + return { async embedQuery() { return [0.1, 0.2]; } }; + }, + async fetchImpl(url, options) { + assert.equal(url, 'https://weaviate.local/v1/graphql'); + assert.equal(JSON.parse(options.body).variables.limit, 2); + return { + ok: true, + async json() { + return { + data: { + Get: { + MemindMemory: [{ content: 'semantic memory', _additional: { id: 'w1' } }], + }, + }, + }; + }, + }; + }, + }); + const output = JSON.parse(stdout.value()); + + assert.equal(report.backendAvailable, true); + assert.equal(output.selectedBackend, 'weaviate'); + assert.equal(output.result.source, 'weaviate'); +}); + +test('runMemoryV2ExternalSmokeCli smokes Redis Streams with injected redis module', async () => { + const stdout = writableBuffer(); + const xAdds = []; + let quitCalled = false; + const report = await runMemoryV2ExternalSmokeCli({ + argv: ['--backend', 'redis-streams'], + env: { + MEMORY_REDIS_STREAMS_ENABLED: '1', + MEMORY_REDIS_STREAMS_URL: 'redis://localhost:6379', + }, + stdout, + async importRedis() { + return { + createClient() { + return { + async connect() {}, + async xAdd(stream, id, fields) { + xAdds.push({ stream, id, fields }); + }, + async quit() { + quitCalled = true; + }, + }; + }, + }; + }, + }); + + assert.equal(report.backendAvailable, true); + assert.equal(report.result.source, 'redis-streams'); + assert.equal(xAdds[0].stream, 'memind:memory-events'); + assert.equal(quitCalled, true); +}); + +test('runMemoryV2ExternalSmokeCli reports fallback when backend is not configured', async () => { + const stdout = writableBuffer(); + const report = await runMemoryV2ExternalSmokeCli({ + argv: ['--backend', 'langgraph'], + env: {}, + stdout, + }); + + assert.equal(report.backendAvailable, false); + assert.equal(report.selectedBackend, 'legacy-conversation-memory'); + assert.equal(report.backendReason, 'not_configured'); +}); diff --git a/scripts/smoke-memory-v2-pgvector.mjs b/scripts/smoke-memory-v2-pgvector.mjs new file mode 100644 index 0000000..858672a --- /dev/null +++ b/scripts/smoke-memory-v2-pgvector.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { runPgvectorMemorySmoke } from '../memory-v2-pgvector-smoke.mjs'; + +const DEFAULT_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL'; + +function usage() { + return ` +Usage: + node scripts/smoke-memory-v2-pgvector.mjs [options] + +Runs a local pgvector read/write smoke test with synthetic rows. + +Options: + --table Table name. Default: memory_embeddings. + --dimensions Embedding dimensions. Default: 3. + --create-schema Ensure schema before smoke. Default: false. + --create-extension Include CREATE EXTENSION IF NOT EXISTS vector when creating schema. + --keep-rows Do not delete synthetic smoke rows. + --url-env Env var containing PostgreSQL URL. Default: ${DEFAULT_URL_ENV}. + -h, --help Show this help. +`.trim(); +} + +export function parseMemoryPgvectorSmokeArgs(argv = []) { + const options = { + tableName: 'memory_embeddings', + dimensions: 3, + createSchema: false, + createExtension: false, + cleanup: true, + urlEnv: DEFAULT_URL_ENV, + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case '--table': + i += 1; + options.tableName = argv[i]; + break; + case '--dimensions': + i += 1; + options.dimensions = Number(argv[i]); + break; + case '--create-schema': + options.createSchema = true; + break; + case '--create-extension': + options.createExtension = true; + break; + case '--keep-rows': + options.cleanup = false; + break; + case '--url-env': + i += 1; + options.urlEnv = argv[i]; + break; + case '-h': + case '--help': + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.tableName) throw new Error('--table requires a value'); + if (!Number.isInteger(options.dimensions) || options.dimensions < 1) { + throw new Error('--dimensions requires a positive integer'); + } + if (!options.urlEnv) throw new Error('--url-env requires a value'); + return options; +} + +export async function runMemoryPgvectorSmokeCli( + argv = process.argv.slice(2), + env = process.env, + { importPg = (specifier) => import(specifier), stdout = process.stdout } = {}, +) { + const options = parseMemoryPgvectorSmokeArgs(argv); + if (options.help) { + stdout.write(`${usage()}\n`); + return { ok: true, mode: 'help' }; + } + + const connectionString = env[options.urlEnv]; + if (!connectionString) { + throw new Error(`pgvector smoke requires ${options.urlEnv} to be set`); + } + + const imported = await importPg('pg'); + const PgPool = imported?.Pool ?? imported?.default?.Pool; + if (typeof PgPool !== 'function') throw new Error('pg module does not export Pool'); + + const pool = new PgPool({ connectionString, max: 1 }); + try { + const result = await runPgvectorMemorySmoke({ + pool, + tableName: options.tableName, + dimensions: options.dimensions, + createSchema: options.createSchema, + createExtension: options.createExtension, + cleanup: options.cleanup, + }); + stdout.write(`${JSON.stringify({ + ...result, + checkedAt: new Date().toISOString(), + }, null, 2)}\n`); + return result; + } finally { + await pool.end(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryPgvectorSmokeCli().then((result) => { + process.exitCode = result.ok ? 0 : 1; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/smoke-memory-v2-pgvector.test.mjs b/scripts/smoke-memory-v2-pgvector.test.mjs new file mode 100644 index 0000000..e800928 --- /dev/null +++ b/scripts/smoke-memory-v2-pgvector.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryPgvectorSmokeArgs, + runMemoryPgvectorSmokeCli, +} from './smoke-memory-v2-pgvector.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryPgvectorSmokeArgs defaults to safe existing-schema smoke', () => { + assert.deepEqual(parseMemoryPgvectorSmokeArgs([]), { + tableName: 'memory_embeddings', + dimensions: 3, + createSchema: false, + createExtension: false, + cleanup: true, + urlEnv: 'MEMORY_PGVECTOR_DATABASE_URL', + help: false, + }); +}); + +test('parseMemoryPgvectorSmokeArgs maps local setup options', () => { + assert.deepEqual( + parseMemoryPgvectorSmokeArgs([ + '--table', + 'memory_smoke', + '--dimensions', + '4', + '--create-schema', + '--create-extension', + '--keep-rows', + '--url-env', + 'TEST_PG_URL', + ]), + { + tableName: 'memory_smoke', + dimensions: 4, + createSchema: true, + createExtension: true, + cleanup: false, + urlEnv: 'TEST_PG_URL', + help: false, + }, + ); +}); + +test('runMemoryPgvectorSmokeCli requires explicit pgvector URL', async () => { + await assert.rejects( + () => runMemoryPgvectorSmokeCli([], {}, { stdout: writableBuffer() }), + /MEMORY_PGVECTOR_DATABASE_URL/, + ); +}); + +test('runMemoryPgvectorSmokeCli opens pool, runs smoke, and closes pool', async () => { + const stdout = writableBuffer(); + let poolEnded = false; + class FakePool { + constructor(options) { + this.options = options; + } + + async query(sql) { + if (/SELECT id, content, type, created_at/.test(sql)) { + return { + rows: [{ + id: 1, + content: 'memory-v2 smoke near vector', + type: 'smoke', + score: 1, + }], + }; + } + return { rows: [] }; + } + + async end() { + poolEnded = true; + } + } + + const result = await runMemoryPgvectorSmokeCli( + ['--create-schema', '--create-extension'], + { MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory' }, + { + stdout, + async importPg(specifier) { + assert.equal(specifier, 'pg'); + return { Pool: FakePool }; + }, + }, + ); + const output = JSON.parse(stdout.value()); + + assert.equal(result.ok, true); + assert.equal(output.ok, true); + assert.equal(output.expectedTopText, 'memory-v2 smoke near vector'); + assert.equal(poolEnded, true); +}); diff --git a/scripts/smoke-memory-v2-qdrant.mjs b/scripts/smoke-memory-v2-qdrant.mjs new file mode 100644 index 0000000..87a2b69 --- /dev/null +++ b/scripts/smoke-memory-v2-qdrant.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { createQdrantHttpClient } from '../memory-v2-qdrant.mjs'; + +const DEFAULT_URL_ENV = 'MEMORY_QDRANT_URL'; + +function usage() { + return ` +Usage: + node scripts/smoke-memory-v2-qdrant.mjs [options] + +Runs a read-only Qdrant search smoke test. It does not create collections or write points. + +Options: + --collection Collection name. Default: MEMORY_QDRANT_COLLECTION or memind_memory. + --vector Query vector CSV. Default: 1,0,0. + --limit Result limit. Default: 1. + --url-env Env var containing Qdrant URL. Default: ${DEFAULT_URL_ENV}. + --api-key-env Env var containing Qdrant API key. Default: MEMORY_QDRANT_API_KEY. + --timeout-ms Request timeout. Default: 3000. + -h, --help Show this help. +`.trim(); +} + +function parseVector(value) { + const vector = String(value ?? '1,0,0') + .split(',') + .map((item) => Number(item.trim())); + if (!vector.length || vector.some((item) => !Number.isFinite(item))) { + throw new Error('--vector must be a comma-separated list of numbers'); + } + return vector; +} + +export function parseMemoryQdrantSmokeArgs(argv = [], env = process.env) { + const options = { + collection: env.MEMORY_QDRANT_COLLECTION || 'memind_memory', + vector: [1, 0, 0], + limit: 1, + urlEnv: DEFAULT_URL_ENV, + apiKeyEnv: 'MEMORY_QDRANT_API_KEY', + timeoutMs: 3000, + help: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case '--collection': + options.collection = argv[++i] ?? ''; + break; + case '--vector': + options.vector = parseVector(argv[++i]); + break; + case '--limit': + options.limit = Number(argv[++i]); + break; + case '--url-env': + options.urlEnv = argv[++i] ?? ''; + break; + case '--api-key-env': + options.apiKeyEnv = argv[++i] ?? ''; + break; + case '--timeout-ms': + options.timeoutMs = Number(argv[++i]); + break; + case '-h': + case '--help': + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + if (!options.collection) throw new Error('--collection requires a value'); + if (!Number.isInteger(options.limit) || options.limit < 1) { + throw new Error('--limit requires a positive integer'); + } + if (!options.urlEnv) throw new Error('--url-env requires a value'); + if (!options.apiKeyEnv) throw new Error('--api-key-env requires a value'); + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1) { + throw new Error('--timeout-ms requires a positive number'); + } + return options; +} + +export async function runMemoryQdrantSmokeCli({ + argv = process.argv.slice(2), + env = process.env, + stdout = process.stdout, + fetchImpl = globalThis.fetch, +} = {}) { + const options = parseMemoryQdrantSmokeArgs(argv, env); + if (options.help) { + stdout.write(`${usage()}\n`); + return { ok: true, mode: 'help' }; + } + const url = env[options.urlEnv]; + if (!url) throw new Error(`Qdrant smoke requires ${options.urlEnv} to be set`); + const client = createQdrantHttpClient({ + url, + apiKey: env[options.apiKeyEnv], + fetchImpl, + timeoutMs: options.timeoutMs, + }); + const points = await client.search({ + collection: options.collection, + vector: options.vector, + limit: options.limit, + }); + const report = { + ok: true, + checkedAt: new Date().toISOString(), + collection: options.collection, + resultCount: points.length, + readOnly: true, + }; + stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMemoryQdrantSmokeCli().then((result) => { + process.exitCode = result.ok ? 0 : 1; + }).catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }); +} diff --git a/scripts/smoke-memory-v2-qdrant.test.mjs b/scripts/smoke-memory-v2-qdrant.test.mjs new file mode 100644 index 0000000..9b5df45 --- /dev/null +++ b/scripts/smoke-memory-v2-qdrant.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseMemoryQdrantSmokeArgs, + runMemoryQdrantSmokeCli, +} from './smoke-memory-v2-qdrant.mjs'; + +function writableBuffer() { + let value = ''; + return { + write(chunk) { + value += String(chunk); + }, + value() { + return value; + }, + }; +} + +test('parseMemoryQdrantSmokeArgs defaults to read-only smoke settings', () => { + assert.deepEqual(parseMemoryQdrantSmokeArgs([], {}), { + collection: 'memind_memory', + vector: [1, 0, 0], + limit: 1, + urlEnv: 'MEMORY_QDRANT_URL', + apiKeyEnv: 'MEMORY_QDRANT_API_KEY', + timeoutMs: 3000, + help: false, + }); +}); + +test('parseMemoryQdrantSmokeArgs maps options', () => { + assert.deepEqual( + parseMemoryQdrantSmokeArgs([ + '--collection', + 'memind_memory_canary', + '--vector', + '0.1,0.2,0.3', + '--limit', + '3', + '--url-env', + 'QDRANT_URL', + '--api-key-env', + 'QDRANT_KEY', + '--timeout-ms', + '500', + ], {}), + { + collection: 'memind_memory_canary', + vector: [0.1, 0.2, 0.3], + limit: 3, + urlEnv: 'QDRANT_URL', + apiKeyEnv: 'QDRANT_KEY', + timeoutMs: 500, + help: false, + }, + ); +}); + +test('runMemoryQdrantSmokeCli requires explicit Qdrant URL', async () => { + await assert.rejects( + () => runMemoryQdrantSmokeCli({ env: {}, stdout: writableBuffer() }), + /MEMORY_QDRANT_URL/, + ); +}); + +test('runMemoryQdrantSmokeCli performs read-only search', async () => { + const stdout = writableBuffer(); + const calls = []; + const report = await runMemoryQdrantSmokeCli({ + argv: ['--collection', 'memind_memory', '--vector', '1,0,0', '--limit', '2'], + env: { + MEMORY_QDRANT_URL: 'http://127.0.0.1:6333', + MEMORY_QDRANT_API_KEY: 'secret', + }, + stdout, + async fetchImpl(url, options) { + calls.push({ url, options }); + return { + ok: true, + async json() { + return { result: [{ id: 'p1' }, { id: 'p2' }] }; + }, + }; + }, + }); + const output = JSON.parse(stdout.value()); + + assert.equal(report.ok, true); + assert.equal(output.readOnly, true); + assert.equal(output.resultCount, 2); + assert.equal(calls.length, 1); + assert.equal(JSON.parse(calls[0].options.body).with_payload, true); +}); diff --git a/server.mjs b/server.mjs index 03a846e..a084fdb 100644 --- a/server.mjs +++ b/server.mjs @@ -164,6 +164,8 @@ import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; import { createSessionSnapshotService } from './session-snapshot.mjs'; import { createConversationMemoryService } from './conversation-memory.mjs'; +import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; +import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createExperienceService } from './experience-service.mjs'; import { attachAsrRoutes } from './asr-proxy.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; @@ -283,6 +285,8 @@ let agentRunGateway = null; let toolGateway = null; let sessionSnapshotService = null; let conversationMemoryService = null; +let memoryV2 = null; +let memoryV2ConfigService = null; let mindSpace = null; let mindSpaceAssets = null; let mindSpaceAudit = null; @@ -516,6 +520,11 @@ async function bootstrapUserAuth() { console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err); }); conversationMemoryService = createConversationMemoryService(pool); + memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + memoryV2 = await createManagedMemoryV2Runtime({ + legacyMemoryService: conversationMemoryService, + configService: memoryV2ConfigService, + }); sessionSnapshotService = createSessionSnapshotService(pool, { conversationMemoryService, }); @@ -528,6 +537,7 @@ async function bootstrapUserAuth() { subscriptionService, sessionSnapshotService, conversationMemoryService, + memoryV2, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); @@ -1866,7 +1876,8 @@ async function syncUserMemoriesIntoSession(userId, sessionId) { } api.post('/user-memory/v1/remember-recent', async (req, res) => { - if (!conversationMemoryService?.isEnabled?.()) { + const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); + if (!memoryStatus?.enabled) { return res.status(503).json({ message: '长期记忆功能未启用' }); } if (!tkmindProxy) { @@ -1886,13 +1897,18 @@ api.post('/user-memory/v1/remember-recent', async (req, res) => { try { const messages = await loadUserVisibleConversation(sessionId); - const result = await conversationMemoryService.saveAndAnalyze( + const result = await memoryV2.write({ + userId: req.currentUser.id, sessionId, - req.currentUser.id, messages, - ); + }); const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); - const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); + const resolved = await memoryV2.resolve({ + userId: req.currentUser.id, + sessionId, + limit: 200, + }); + const memories = Array.isArray(resolved?.memories) ? resolved.memories : []; return res.json({ ok: true, analyzed: result.analyzed ?? 0, @@ -1906,7 +1922,8 @@ api.post('/user-memory/v1/remember-recent', async (req, res) => { }); api.post('/user-memory/v1/sync', async (req, res) => { - if (!conversationMemoryService?.isEnabled?.()) { + const memoryStatus = await memoryV2?.getStatus?.().catch(() => null); + if (!memoryStatus?.enabled) { return res.status(503).json({ message: '长期记忆功能未启用' }); } const capabilityState = await ensureUserMemoryCapability(req, res); @@ -1922,9 +1939,17 @@ api.post('/user-memory/v1/sync', async (req, res) => { } try { - const result = await conversationMemoryService.analyzeUser(req.currentUser.id); + const result = await memoryV2.compact({ + userId: req.currentUser.id, + sessionId, + }); const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); - const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); + const resolved = await memoryV2.resolve({ + userId: req.currentUser.id, + sessionId, + limit: 200, + }); + const memories = Array.isArray(resolved?.memories) ? resolved.memories : []; return res.json({ ok: true, analyzed: result.analyzed ?? 0, diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 67550b9..1fc6798 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -823,6 +823,7 @@ export function createTkmindProxy({ subscriptionService, sessionSnapshotService, conversationMemoryService, + memoryV2, }) { const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : []; const primaryTarget = targets[0] ?? apiTarget ?? ''; @@ -1025,10 +1026,48 @@ export function createTkmindProxy({ aiderTimeoutMs: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 600_000), openhandsTimeoutMs: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 900_000), }, + memory: memoryV2?.getStatus + ? await Promise.resolve(memoryV2.getStatus()) + : { + enabled: Boolean(conversationMemoryService?.isEnabled?.()), + backend: 'legacy', + selectedBackend: conversationMemoryService ? 'legacy-conversation-memory' : null, + profileEnabled: false, + eventLogEnabled: Boolean(conversationMemoryService?.isEnabled?.()), + vectorEnabled: false, + failOpen: true, + backends: [ + { + name: 'legacy-conversation-memory', + available: Boolean(conversationMemoryService), + supports: { + resolve: Boolean(conversationMemoryService?.listMemories), + write: Boolean(conversationMemoryService?.saveAndAnalyze), + compact: Boolean(conversationMemoryService?.analyzeUser), + }, + }, + ], + }, targets: targetStatuses, }; } + async function resolveUserMemories(userId, { sessionId = null, query = null, limit = 40 } = {}) { + if (!userId) return []; + if (memoryV2?.resolve) { + const resolved = await memoryV2.resolve({ + userId, + sessionId, + query, + limit, + }).catch(() => null); + return Array.isArray(resolved?.memories) ? resolved.memories : []; + } + return conversationMemoryService?.listMemories + ? conversationMemoryService.listMemories(userId, { limit }).catch(() => []) + : []; + } + async function startSessionForUser( userId, { @@ -1075,9 +1114,7 @@ export function createTkmindProxy({ } } const publishLayout = await userAuth.getUserPublishLayout(userId); - const userMemories = conversationMemoryService?.listMemories - ? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []) - : []; + const userMemories = await resolveUserMemories(userId, { sessionId: session.id, limit: 40 }); await reconcileAgentSession( (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init), session.id, @@ -1178,15 +1215,21 @@ export function createTkmindProxy({ return userAuth.getAgentSessionPolicy(userId); } - async function reconcileSessionPolicyForUser(userId, sessionId, { toolMode = 'chat' } = {}) { + async function reconcileSessionPolicyForUser( + userId, + sessionId, + { toolMode = 'chat', query = null } = {}, + ) { if (!userId || !sessionId) return; const target = await resolveTarget(sessionId); const workingDir = await userAuth.resolveWorkingDir(userId); const sessionPolicy = await getSessionPolicyForToolMode(userId, toolMode); const publishLayout = await userAuth.getUserPublishLayout(userId); - const userMemories = conversationMemoryService?.listMemories - ? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []) - : []; + const userMemories = await resolveUserMemories(userId, { + sessionId, + query, + limit: 40, + }); await reconcileAgentSession( (pathname, init) => apiFetch(target, apiSecret, pathname, init), sessionId, @@ -1226,7 +1269,10 @@ export function createTkmindProxy({ throw err; } - await reconcileSessionPolicyForUser(userId, sessionId, { toolMode }); + await reconcileSessionPolicyForUser(userId, sessionId, { + toolMode, + query: firstUserText(userMessage), + }); await applySessionLlmProvider(sessionId); const user = await userAuth.getUserById(userId); @@ -1358,11 +1404,10 @@ export function createTkmindProxy({ } } const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id); - const userMemories = conversationMemoryService?.listMemories - ? await conversationMemoryService - .listMemories(req.currentUser.id, { limit: 40 }) - .catch(() => []) - : []; + const userMemories = await resolveUserMemories(req.currentUser.id, { + sessionId: session.id, + limit: 40, + }); const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init); try { await reconcileAgentSession(api, session.id, { @@ -1430,11 +1475,10 @@ export function createTkmindProxy({ const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id); const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id); const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id); - const userMemories = conversationMemoryService?.listMemories - ? await conversationMemoryService - .listMemories(req.currentUser.id, { limit: 40 }) - .catch(() => []) - : []; + const userMemories = await resolveUserMemories(req.currentUser.id, { + sessionId, + limit: 40, + }); try { await reconcileAgentSession( (pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init), diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 1097f53..c7ce91a 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -1,13 +1,115 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { createServer } from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { buildVisionPayload, + createTkmindProxy, sanitizePublicHtmlLinksInText, sanitizeSessionConversationPublicHtmlLinks, } from './tkmind-proxy.mjs'; +import { createMemoryV2 } from './memory-v2.mjs'; + +async function withFakeGoosedSession(handler) { + const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-')); + const harnessEntries = []; + let server; + + try { + server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const rawBody = Buffer.concat(chunks).toString('utf8'); + const body = rawBody ? JSON.parse(rawBody) : {}; + + if (req.method === 'POST' && req.url === '/agent/start') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 'session-1' })); + return; + } + if (req.method === 'POST' && req.url === '/agent/update_session') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'GET' && req.url === '/sessions/session-1') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'session-1', + working_dir: workingDir, + goose_mode: 'chat', + })); + return; + } + if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + extensions: [{ name: 'memory', available_tools: [] }], + })); + return; + } + if (req.method === 'POST' && req.url === '/sessions/session-1/reply') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'POST' && req.url === '/agent/harness_remember') { + harnessEntries.push(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === 'POST' && req.url === '/agent/harness_bootstrap') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: `unexpected ${req.method} ${req.url}` })); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return await handler({ + apiTarget: `http://127.0.0.1:${port}`, + workingDir, + harnessEntries, + }); + } finally { + if (server?.listening) { + await new Promise((resolve) => server.close(resolve)); + } + fs.rmSync(workingDir, { recursive: true, force: true }); + } +} + +function createMemoryTestUserAuth(workingDir) { + return { + async resolveWorkingDir() { + return workingDir; + }, + async getAgentSessionPolicy() { + return { + gooseMode: 'chat', + enableContextMemory: true, + extensionOverrides: [{ name: 'memory', available_tools: [] }], + }; + }, + async getUserPublishLayout() { + return { + displayName: 'John', + username: 'john', + slug: 'john', + }; + }, + async registerAgentSession() {}, + async getSessionTarget() { + return { target: null, node: 0 }; + }, + }; +} test('sanitizePublicHtmlLinksInText downgrades missing own markdown public html links', () => { const owner = `test-user-${Date.now()}-markdown-missing`; @@ -148,3 +250,206 @@ test('buildVisionPayload injects public standard image urls for page generation' assert.match(text, /无需 cookie 的公开压缩标准图片/); assert.match(text, /不得改写图片里人物的年龄、性别、人数或主体关系/); }); + +test('startSessionForUser resolves memories through Memory V2 facade', async () => { + let resolveInput = null; + await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: createMemoryTestUserAuth(workingDir), + memoryV2: { + async resolve(input) { + resolveInput = input; + return { + memories: [{ label: 'interest', text: '用户关注 Memory V2 架构边界' }], + }; + }, + }, + }); + + await proxy.startSessionForUser('user-1'); + + assert.deepEqual(resolveInput, { + userId: 'user-1', + sessionId: 'session-1', + query: null, + limit: 40, + }); + assert.ok( + harnessEntries.some((entry) => String(entry.content ?? '').includes('用户关注 Memory V2 架构边界')), + ); + }); +}); + +test('startSessionForUser does not inject memories when Memory V2 is disabled', async () => { + let legacyTouched = false; + await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: createMemoryTestUserAuth(workingDir), + memoryV2: createMemoryV2({ + logger: { warn() {} }, + legacyMemoryService: { + async listMemories() { + legacyTouched = true; + return [{ label: 'fact', text: 'disabled memory should not appear' }]; + }, + }, + env: { MEMORY_ENABLED: '0' }, + }), + }); + + await proxy.startSessionForUser('user-1'); + + assert.equal(legacyTouched, false); + assert.equal( + harnessEntries.some((entry) => String(entry.content ?? '').includes('disabled memory should not appear')), + false, + ); + }); +}); + +test('getRuntimeStatus exposes Memory V2 status without new runtime paths', async () => { + await withFakeGoosedSession(async ({ apiTarget, workingDir }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: createMemoryTestUserAuth(workingDir), + memoryV2: createMemoryV2({ + logger: { warn() {} }, + backends: [ + { + name: 'legacy-conversation-memory', + async resolve() { + return { memories: [] }; + }, + }, + ], + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'legacy', + }, + }), + }); + + const status = await proxy.getRuntimeStatus(); + + assert.equal(status.memory.enabled, true); + assert.equal(status.memory.backend, 'legacy'); + assert.equal(status.memory.selectedBackend, 'legacy-conversation-memory'); + assert.deepEqual(status.memory.backends[0].supports, { + resolve: true, + write: false, + compact: false, + }); + }); +}); + +test('getRuntimeStatus preserves Memory V2 status contract for release gates', async () => { + await withFakeGoosedSession(async ({ apiTarget, workingDir }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: createMemoryTestUserAuth(workingDir), + memoryV2: createMemoryV2({ + logger: { warn() {} }, + legacyMemoryService: { + async listMemories() { + return []; + }, + async saveAndAnalyze() { + return { saved: 1, analyzed: 1, memories: 1 }; + }, + async analyzeUser() { + return { analyzed: 1, memories: 1 }; + }, + }, + env: { + MEMORY_ENABLED: '1', + MEMORY_BACKEND: 'qdrant', + MEMORY_FAIL_OPEN: '1', + }, + }), + }); + + const status = await proxy.getRuntimeStatus(); + const memory = status.memory; + const byName = new Map(memory.backends.map((backend) => [backend.name, backend])); + + assert.deepEqual(Object.keys(memory).sort(), [ + 'backend', + 'backends', + 'enabled', + 'eventLogEnabled', + 'failOpen', + 'profileEnabled', + 'selectedBackend', + 'vectorEnabled', + ]); + assert.equal(memory.enabled, true); + assert.equal(memory.backend, 'qdrant'); + assert.equal(memory.selectedBackend, 'legacy-conversation-memory'); + assert.equal(memory.failOpen, true); + assert.deepEqual(byName.get('legacy-conversation-memory').supports, { + resolve: true, + write: true, + compact: true, + }); + assert.equal(byName.get('qdrant').available, false); + assert.equal(byName.get('qdrant').reason, 'not_configured'); + assert.equal(byName.get('pgvector').category, 'semantic'); + assert.equal(byName.get('mem0').category, 'extraction'); + assert.equal(byName.get('letta').category, 'lifecycle'); + assert.equal(byName.get('langgraph').category, 'policy'); + }); +}); + +test('submitSessionReplyForUser passes current prompt to Memory V2 resolve before existing reply path', async () => { + let resolveInput = null; + await withFakeGoosedSession(async ({ apiTarget, workingDir }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: { + ...createMemoryTestUserAuth(workingDir), + async ownsSession() { + return true; + }, + async canUseChat() { + return { ok: true }; + }, + async getUserById() { + return { id: 'user-1' }; + }, + async resolveUserPolicies() { + return { unrestricted: true, policies: {} }; + }, + }, + memoryV2: { + async resolve(input) { + resolveInput = input; + return { memories: [] }; + }, + }, + }); + + await proxy.submitSessionReplyForUser( + 'user-1', + 'session-1', + 'request-1', + { + role: 'user', + content: [{ type: 'text', text: '帮我设计 memory-chain 下一阶段' }], + }, + ); + + assert.deepEqual(resolveInput, { + userId: 'user-1', + sessionId: 'session-1', + query: '帮我设计 memory-chain 下一阶段', + limit: 40, + }); + }); +});