Compare commits

..

414 Commits

Author SHA1 Message Date
Peter Steinberger
bc4e8d46d2 ci: make changed-scope diff resilient on pr reruns 2026-02-16 12:48:32 +01:00
Peter Steinberger
1b64548caf fix: serialize cron force-run persistence 2026-02-16 11:35:52 +01:00
Peter Steinberger
9a6f7c9b23 test: retry cron delivery temp cleanup on windows 2026-02-16 11:29:14 +01:00
Peter Steinberger
7a7f8e480c fix: restore CI command and memory status behavior 2026-02-16 11:22:24 +01:00
Peter Steinberger
dc7063af88 fix(ci): resolve adabot type-check regressions 2026-02-16 11:09:05 +01:00
Tarun Sukhani
6ff248fd4e memory-neo4j: task-aware memory filtering (3 layers)
Layer 1 — Recall-time filter (task-filter.ts):
- New module that reads TASKS.md completed tasks and filters recalled
  memories that match completed task IDs or keywords
- Integrated into auto-recall hook as Feature 3 (after score/dedup filters)
- 60-second cache to avoid re-parsing TASKS.md on every message
- 29 new tests

Layer 2 — Sleep cycle Phase 7 (task-memory cleanup):
- New phase cross-references completed tasks with stored memories
- LLM classifies each matched memory as 'lasting' (keep) or 'noise' (delete)
- Conservative: keeps memories on any doubt or LLM failure
- Scans only tasks completed within last 7 days
- New searchMemoriesByKeywords() method on neo4j client
- 16 new tests

Layer 3 — Memory task metadata (taskId field):
- Optional taskId field on MemoryNode, StoreMemoryInput, and search results
- Auto-tags memories during auto-capture when exactly 1 active task exists
- Precise taskId-based filtering at recall time (complements Layer 1)
- findMemoriesByTaskId() and clearTaskIdFromMemories() on neo4j client
- taskId flows through vector, BM25, and graph search signals + RRF fusion
- 20 new tests

All 669 memory-neo4j tests pass. Zero regressions in full suite.
All changes are backward compatible — existing memories without taskId
continue to work. No migration needed.
2026-02-16 17:56:39 +08:00
Tarun Sukhani
18b8007d23 memory-neo4j: improve tag coverage with stronger extraction + retroactive tagging
- Strengthen extraction prompt to always generate 2-4 tags per memory
- Add Phase 2b: Retroactive Tagging to sleep cycle for untagged memories
- Include 'skipped' memories in extraction pipeline (imported memories)
- Add listUntaggedMemories() helper to neo4j-client
- Add extractTagsOnly() lightweight prompt for tag-only extraction
- Add CLI display for Phase 2b stats

Fixes: 79% of memories had zero tags due to weak prompt guidance
and imported memories never going through extraction.
2026-02-16 17:56:39 +08:00
Tarun Sukhani
f093be7b3a fix(telegram): prevent subsequent final payloads from overwriting preview message
When multiple final payloads were dispatched (e.g., model text + tool error
warning), each one tried to edit the draft preview message, causing the last
payload (tool error) to replace the model's text. Guard the preview-edit path
with `!finalizedViaPreviewMessage` so only the first final payload edits the
preview; subsequent payloads are sent as separate messages via deliverReplies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
fee43d505d refactor(memory-neo4j): remove in-process auto sleep cycle, use system cron instead
Sleep cycle is now triggered by a system cron job (`0 3 * * *`) calling
`openclaw memory neo4j sleep` rather than an in-process 6-hour interval
timer with mutex. Simpler, more reliable, and easier to manage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
1bc6cdd00c fix: classify read-only exec/bash commands as non-mutating
Read-only commands like find, ls, grep no longer trigger forced error
messages when they exit with non-zero codes, preserving the LLM's
actual response instead of replacing it with a tool error warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
85ae75882c feat(memory-neo4j): add signal attribution, sleep --report, and health dashboard
- Search results now include per-signal attribution (vec/bm25/graph rank+score)
  threaded through RRF fusion to memory_recall output and auto-recall debug logs
- New --report flag on sleep command shows post-cycle quality metrics
  (extraction coverage, entity graph density, decay distribution)
- New `health` subcommand with 5-section dashboard: memory overview,
  extraction health, entity graph, tag health, decay distribution
  Supports --agent scoping and --json output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
8d88f2f8de fix: handle undefined agentId in sandbox registry listing
resolveSandboxAgentId returns string | undefined but was passed
directly to resolveConfiguredImage which expects string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
c33a0f21cc updated pnpm 2026-02-16 17:56:39 +08:00
Tarun Sukhani
1a8e46b037 feat(memory-neo4j): add automatic sleep cycle + cleanup sleep/extraction pipeline
Auto-trigger sleep cycle (dedup, extraction, decay, cleanup) in the
background after agent_end when 6h+ have elapsed. Configurable via
sleepCycle.auto and sleepCycle.autoIntervalMs. Removes need for
external cron job with regular gateway usage.

Also includes: removal of Pareto promotion (replaced by manual core
promotion), entity dedup in sleep cycle, and sleep cycle pipeline
cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
0a55711110 fix: guard against undefined path in bootstrap file entries
The session-context hook pushed bootstrap entries without the required
`path` property, causing a TypeError in buildInjectedWorkspaceFiles when
it called .replace() on undefined. Add fallback to file.name when path
is missing, and skip entries with no path in the report builder.

Also add stack trace logging to lane task errors and embedded agent
failures to make future debugging faster.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
fc92b05046 push to repos 2026-02-16 17:56:39 +08:00
Tarun Sukhani
624ba65554 check key 2026-02-16 17:56:39 +08:00
Tarun Sukhani
a5e0487647 fix: allow plugin CLI registration for builtin memory command
The performance optimization that skips plugin loading for builtin
commands prevented memory-neo4j from registering its "neo4j" subcommand,
causing "openclaw memory neo4j sleep" to fail with "unknown command".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
68d4ca1e0d updated script 2026-02-16 17:56:39 +08:00
Tarun Sukhani
ed5d6db833 updated script 2026-02-16 17:56:39 +08:00
Tarun Sukhani
a170e25494 task continuity: TASKS.md ledger, post-compaction recovery, entity dedup, credential scanning
Add task ledger (TASKS.md) parsing and stale-task archival for maintaining
agent task state across context compactions. Post-compaction recovery injects
memory_recall + TASKS.md read steps after auto-compaction. Sleep cycle gains
entity dedup (Phase 1d) and credential scanning. Memory flush now extracts
active task checkpoints. Compaction instructions prioritize active tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
4d54736b98 memory-neo4j: single-use tag pruning, alias-based entity dedup, tag normalization
- Add findSingleUseTags() to prune tags with only 1 reference after 14 days
- Enhance findDuplicateEntityPairs() to match on entity aliases
- Add normalizeTagName() to collapse hyphens/underscores to spaces
- Monitor 'other' category accumulation in sleep cycle Phase 2
- Tighten extraction prompt with explicit entity blocklist (80 terms)
- Raise auto-capture threshold from 0.5 to 0.65
- Fix tests for entity dedup phase and skipPromotion default
2026-02-16 17:56:39 +08:00
Tarun Sukhani
08b08c66f1 memory-neo4j: filter open proposals and cron noise from memory
Open proposals ("Want me to...?", "Should I...?") are dangerous in
long-term memory because other sessions interpret them as active
instructions and attempt to carry them out. This adds:
- Attention gate patterns for cron delivery outputs and assistant proposals
- Extractor scoring rules to rate proposals/action items as low importance
- Sleep-cycle Phase 7 to retroactively clean existing noise-pattern memories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
e85dd19092 updated killmode to mixed 2026-02-16 17:56:39 +08:00
Tarun Sukhani
7704e5cc44 fix: remove dead localTime param from formatLogTimestamp after rebase
The upstream added --local-time as an opt-in flag, but our branch
already makes all timestamps local. Remove the dead parameter,
CLI option, and update tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
4082d2657e memory-neo4j: improve extraction quality and sleep-cycle tuning
- Add attention gate patterns for voice mode context and session
  completion summaries (ephemeral, not user knowledge)
- Rewrite importance rating prompt with detailed scoring guide and
  concrete examples to reduce over-scoring of assistant narration
- Raise dedup safety bound from 500 to 2000 pairs
- Add skipPromotion option (default true) so core tier stays
  user-curated only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
8762697d22 memory-neo4j: enhance list and stats CLI with bar graphs and memory listing
- stats: show per-agent bar graphs for category counts and avg importance
- list: show actual memory contents grouped by agent/category with importance bars
- list: add --agent, --category, --limit filters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
8086915187 fix: suppress low context window warning for explicitly configured models
When a model's contextWindow is explicitly set in modelsConfig (openclaw.json),
don't warn about it being below 32k. The user deliberately chose that value.

The warning still fires for auto-detected (model metadata) and default sources.
shouldBlock is unaffected — hard minimum still enforced regardless of source.

Closes #13933
2026-02-16 17:56:39 +08:00
Tarun Sukhani
0149f39e72 agents: lower context window hard minimum from 16k to 1024
Allow small-context models like ollama/qwen2.5:7b-2k (2048 tokens) to
run without being blocked by the guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
0f6a15deca memory-neo4j: fix undefined variable in graph search agent filter
The agentFilter used `m.agentId` in both the direct-mentions and N-hop
sections of the Cypher query, but `m` is out of scope in the N-hop
section where the Memory node is aliased as `m2`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
e9b9da5a1f memory-neo4j: add userPinned flag, remove demotion, add benchmarking, audit fixes
- Add userPinned boolean on Memory nodes: user-stored core memories are
  immune from importance recalculation, decay, and pruning. Only removable
  via memory_forget. Importance locked at 1.0.
- Add listCoreForInjection(): always injects ALL userPinned core memories
  plus top N non-pinned core memories by importance (no silent drop-off
  for user-pinned memories regardless of maxEntries cap).
- Remove core demotion entirely: promotion is now one-way. Bad core
  memories are handled manually via memory_forget.
- Add [bench] performance timing to auto-recall, auto-capture, core
  memory injection, core refresh, and hybridSearch.
- Audit fixes: remove dead entity/tag methods, dead test blocks, orphaned
  demoteFromCore docstring, unnecessary .slice() in graphSearch.
- Refactor attention gate into shared checks for user/assistant gates.
- Consolidate LLM client, message utils, and config helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
e562ff4e31 memory-neo4j: tighten attention gate filters and add session skip patterns
Strip voice chat timestamps, conversation metadata blocks, and queued
message wrappers before the attention gate evaluates content. Expand
assistant narration patterns to catch UI interaction verbs, filler
responses ("I'm here", "Sure, tell me"), and page/step progress.
Add configurable autoCaptureSkipPattern and autoRecallSkipPattern
for bypassing memory on latency-sensitive sessions (e.g. voice).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:39 +08:00
Tarun Sukhani
a5ebbe4b55 memory-neo4j: make semantic dedup cap and LLM concurrency configurable
The hardcoded MAX_SEMANTIC_DEDUP_PAIRS (50) and LLM_CONCURRENCY (8) were
designed for expensive cloud LLM calls. For local Ollama inference these
caps are unnecessarily restrictive, especially during long sleep windows.

- Add maxSemanticDedupPairs to SleepCycleOptions (default: 500)
- Add llmConcurrency to SleepCycleOptions (default: 8)
- Add --max-semantic-pairs and --concurrency CLI flags
- Raise semantic dedup default from 50 → 500 pairs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
e0e98c2c0d memory-neo4j: purge noise, tighten auto-capture filters, cap sleep cycle dedup
- Add 11 ASSISTANT_NARRATION_PATTERNS to reject play-by-play self-talk
  ("Let me check...", "I'll run...", "Starting...", "Good! The...", etc.)
- Cap Phase 1b semantic dedup to 50 pairs (sorted by similarity desc)
  to prevent sleep cycle timeouts on large memory sets
- Raise user auto-capture importance threshold from 0.3 to 0.5
- Raise assistant auto-capture importance threshold from 0.7 to 0.8
- Raise MIN_WORD_COUNT from 5 to 8 for user attention gate
- Neo4j cleanup: deleted 155 noise entries (394→242 memories),
  recategorized 2 misplaced entries, stripped Slack metadata from 1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
309c5b6029 memory-neo4j: add --skip-semantic flag to skip LLM-based dedup in sleep cycle
Adds skipSemanticDedup option to runSleepCycle that skips Phase 1b
(semantic dedup) and Phase 1c (conflict detection), both of which
require LLM calls. Useful for fast/cheap sleep runs that only need
vector-based dedup and decay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
d4e3549ed2 audit: fix 18 defects across gateway SSE streaming, voice-call security, and telephony
Gateway (pipecat compatibility):
- openai-http: add finish_reason:"stop" on final SSE chunk, fix ID format
  (chatcmpl- not chatcmpl_), capture timestamp once, use delta only, add
  writable checks and flush after writes
- http-common: add TCP_NODELAY, X-Accel-Buffering:no, flush after writes,
  writable checks on writeDone
- agent-events: fix seqByRun memory leak in clearAgentRunContext

Voice-call security:
- manager.ts, twiml.ts, twilio.ts: escape voice/language XML attributes
  to prevent XML injection
- voice-mapping: strip control characters in escapeXml

Voice-call bugs:
- tts-openai: fix broken resample24kTo8k (interpolation frac always 0)
- stt-openai-realtime: close zombie WebSocket on connection timeout
- telnyx: extract direction/from/to for inbound calls (were silently dropped)
- plivo: clean up 5 internal maps on terminal call states (memory leak)
- twilio: clean up callWebhookUrls on terminal call states

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
806c5e2d13 memory-neo4j: fix high-severity review findings — security, concurrency, silent failures
- Add safety comment for RELATIONSHIP_TYPE_PATTERN Cypher interpolation
- Add concurrency batching (8) to findDuplicateClusters vector queries
- Bounds-validate memory_recall limit parameter (1-50)
- Fix maxRetries comment (default 2 = 3 attempts, not 1 = 2)
- Fix countByExtractionStatus passing undefined agentId to Cypher
- Fix assistant auto-capture silently disabled when extraction disabled
- Add agentId scoping to findSimilar (dedup + auto-capture)
- Fix BM25 single-result normalization (0.5 instead of inflated 1.0)
- Wrap pruneMemories in retryOnTransient for resilience
- Use UNWIND batch update in reindex instead of N individual queries
- Raise auto-delete threshold from 0.9 to 0.95 to reduce false positives

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
03e4768732 memory-neo4j: code review fixes — search, decay, dedup, retry, tests
Search: fix entity classification order (proper nouns before word count),
BM25 min-max normalization with floor, empty query guard.

Decay: retrieval-reinforced half-life with effective age anchored to
lastRetrievedAt, parameterized category curves (no string interpolation).

Dedup: transfer TAGGED relationships to survivor during merge.

Orphans: use EXISTS pattern instead of stale mentionCount.

Embeddings: Ollama retry with exponential backoff (2 retries, 1s base).

Config: resolve env vars in neo4j.uri, re-export MemoryCategory from schema.

Extractor: abort-aware batch delay, anonymize prompt examples.

Tests: add 80 tests for index.ts (attention gates, message extraction,
wrapper stripping). Full suite: 480 tests across 8 files, all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
cb1c0658fc fix stray brace in memory-lancedb and bump pnpm to 10.29.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
b70cecc307 memory-neo4j: long-term fixes — streaming, abort signals, configurable depth/decay
- Semantic dedup vector pre-screen: skip LLM calls when cosine similarity < 0.8
- Propagate abort signal into sleep cycle phases and extraction pipeline
- Configurable graph search depth (1-3 hops) via graphSearchDepth config
- Streaming extraction: SSE-based callOpenRouterStream with abort responsiveness
- Configurable per-category decay curves for memory consolidation
- Updated tests with SSE streaming mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
1f80d4f0d2 memory-neo4j: medium-term fixes — index, batching, parallelism, module extraction
- Add composite index on (agentId, category) for faster filtered queries
- Combine graph search into single UNION Cypher query (was 2 sequential)
- Parallelize conflict resolution with LLM_CONCURRENCY chunks
- Batch entity operations (merge, mentions, relationships, tags, category,
  extraction status) into a single managed transaction
- Make auto-capture fire-and-forget with shared captureMessage helper
- Extract attention-gate.ts and message-utils.ts modules from index.ts
  and extractor.ts for better separation of concerns
- Update tests to match new batched/combined APIs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
1ae3afbd6b memory-neo4j: code review quick wins — security, perf, docs fixes
- Fix initPromise retry: reset to null on failure so subsequent calls
  retry instead of returning cached rejected promise
- Remove dead code: findPromotionCandidates, findDemotionCandidates,
  calculateEffectiveImportance (~190 lines, never called)
- Add agentId filter to deleteMemory() to prevent cross-agent deletion
- Fix phase label swaps: 1b=Semantic Dedup, 1c=Conflict Detection
  (CLI banner, phaseNames map, SleepCycleResult/Options type comments)
- Add autoRecallMinScore and coreMemory config to plugin JSON schema
  so the UI can validate and display these options
- Add embedding LRU cache (200 entries, SHA-256 keyed) to eliminate
  redundant API calls across auto-recall, auto-capture, and tools
- Add Ollama concurrency limiter (chunks of 4) to prevent thundering
  herd on single-threaded embedding server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
d311438cb4 memory-neo4j: fix Ollama embedding context overflow for token-dense inputs 2026-02-16 17:56:38 +08:00
Tarun Sukhani
27cb766209 memory-neo4j: strengthen auto-capture filtering and add Slack metadata stripping
- Raise MIN_CAPTURE_CHARS from 10 to 30 to reject trivially short messages
- Add noise patterns for conversational filler (haha, lol, hmm, etc.)
- Add noise pattern to reject /new and /reset session prompts
- Raise importance threshold for assistant auto-captures to >= 0.7
- Add Slack protocol prefix/suffix stripping in stripMessageWrappers()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
4a3d424890 updated dependency 2026-02-16 17:56:38 +08:00
Tarun Sukhani
fff48a146d memory-neo4j: add auto-recall filtering, assistant capture, importance scoring, conflict detection
Five high-impact improvements to the memory system:

1. Min RRF score threshold on auto-recall (default 0.25) — filters low-relevance
   results before injecting into context
2. Deduplicate auto-recall against core memories already present in context
3. Capture assistant messages (decisions, recommendations, synthesized facts)
   with stricter attention gating and "auto-capture-assistant" source type
4. LLM-judged importance scoring at capture time (0.1-1.0) with 5s timeout
   fallback to 0.5, replacing the flat 0.5 default
5. Conflict detection in sleep cycle (Phase 1b) — finds contradictory memories
   sharing entities, uses LLM to resolve, invalidates the loser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
9f6372241c hooks: fire session_end on /new and /reset so plugins clear bootstrap state 2026-02-16 17:56:38 +08:00
Tarun Sukhani
9cfb56696f memory-neo4j: extract stripMessageWrappers helper, use in cleanup for accurate gating 2026-02-16 17:56:38 +08:00
Tarun Sukhani
6747967b83 memory-neo4j: strip channel metadata wrappers, reject system infra messages in attention gate 2026-02-16 17:56:38 +08:00
Tarun Sukhani
7674fa8c15 memory-neo4j: cleanup targets auto-capture only, trust explicit memory_store 2026-02-16 17:56:38 +08:00
Tarun Sukhani
91efe2e432 memory-neo4j: tighten attention gate, add gate to memory_store, add cleanup command 2026-02-16 17:56:38 +08:00
Tarun Sukhani
ae1d35aab3 fix: remove unnecessary type assertion in neo4j config 2026-02-16 17:56:38 +08:00
Tarun Sukhani
bcbeba400e memory-neo4j: strip injected context blocks, add core category, widen embeddings context 2026-02-16 17:56:38 +08:00
Tarun Sukhani
c002574371 logging: standardize subsystem compact format, add timestamp tests 2026-02-16 17:56:38 +08:00
Tarun Sukhani
f1753aa336 logging: use local time (with tz offset) everywhere instead of UTC 2026-02-16 17:56:38 +08:00
Tarun Sukhani
516459395c updated time 2026-02-16 17:56:38 +08:00
Tarun Sukhani
b0a9eb9407 memory-neo4j: drop entity vector embeddings (use fulltext search) 2026-02-16 17:56:38 +08:00
Tarun Sukhani
f1f32d5723 feat(memory-neo4j): log auto-capture at info level even when 0 stored 2026-02-16 17:56:38 +08:00
Tarun Sukhani
5761b23760 chore: fix update-and-restart to rebase on origin/main and force-push 2026-02-16 17:56:38 +08:00
Tarun Sukhani
370adb0f4b memory-neo4j: add 'openclaw memory neo4j index' reindex command
Adds a CLI command to re-embed all Memory and Entity nodes after
changing the embedding model or provider. Drops old vector indexes,
re-embeds in batches via the configured provider, and recreates
indexes with the correct dimensions.
2026-02-16 17:56:38 +08:00
Tarun Sukhani
bf5a7a05dd sandbox: scope skill loading to workspace for sandboxed agents
Prevents managed/bundled skill file paths from leaking into sandboxed
agent skill snapshots, which caused 'path escapes sandbox root' errors.
Adds scopeToWorkspace option to loadSkillEntries/buildWorkspaceSkillSnapshot.
Also fixes stale Docker mount detection on container probe failure.
2026-02-16 17:56:38 +08:00
Tarun Sukhani
50f095ecb0 chore: fix lint curly brace in embeddings.ts 2026-02-16 17:56:38 +08:00
Tarun Sukhani
8e5fe5fc14 memory-neo4j: add context-length-aware embedding truncation 2026-02-16 17:56:38 +08:00
Tarun Sukhani
e65b052d27 logging: fix sub-logger inheriting undefined minLevel from parent 2026-02-16 17:56:38 +08:00
Tarun Sukhani
f5859e09ab logging: fix inverted levelToMinLevel mapping vs tslog v4 level IDs 2026-02-16 17:56:38 +08:00
Tarun Sukhani
d096055a4b chore: fix lint errors in memory-neo4j 2026-02-16 17:56:38 +08:00
Tarun Sukhani
0908731c54 logging: isolate test logs to /tmp/openclaw-test under vitest 2026-02-16 17:56:38 +08:00
Tarun Sukhani
3082c53a76 memory-neo4j: harden error handling, concurrency safety, config validation + add tests 2026-02-16 17:56:38 +08:00
Tarun Sukhani
c1371b639e memory-neo4j: configurable extraction model + sleep cycle optimizations
- Add extraction config section (apiKey, model, baseUrl) to plugin schema
  with env-var fallback and Ollama/local LLM support (no API key required)
- Add category classification to extraction prompt; update memories from
  'other' to LLM-assigned category
- Reorder sleep phases: extraction before decay
- Parallelize extraction (3 concurrent via Promise.allSettled)
- Pre-compute effective scores once and reuse for promotion/demotion
- Replace O(n²) Cartesian dedup with per-memory HNSW vector index queries
- Use mentionCount for orphan entity detection instead of subquery
- Remove dead auto-capture code (evaluateAutoCapture, CaptureItem, etc.)
2026-02-16 17:56:38 +08:00
Tarun Sukhani
66f9f972b2 chore: remove unused imports in mid-session-refresh test
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
1e4ffdcec8 memory-neo4j: implement mid-session core memory refresh
Add `coreMemory.refreshAtContextPercent` config option to re-inject
core memories when context usage exceeds a threshold. This counters
the "lost in the middle" phenomenon documented by Liu et al. (2023).

Implementation:
- Extend before_agent_start hook event with context usage info
- Pass contextWindowTokens and estimatedUsedTokens to hooks
- Track mid-session refresh per session to prevent over-refreshing
- Clear refresh tracking on compaction
- Add comprehensive tests

Based on research: Liu et al., "Lost in the Middle: How Language
Models Use Long Contexts" (Stanford, 2023)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
007daf3c27 cli: show memory plugins in openclaw memory status
Detect configured memory plugins (memory-neo4j, memory-lancedb) and show
their status alongside core memory search. Provides helpful hints about
plugin-specific commands when plugins are enabled.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
e7ac300b7e memory-neo4j: add Pareto-based memory ecosystem with retrieval tracking
Implement retrieval tracking and Pareto-based memory consolidation:

- Track retrievalCount and lastRetrievedAt on every search
- Effective importance formula: importance × freq_boost × recency_factor
- Seven-phase sleep cycle: dedup, pareto scoring, promotion, demotion,
  decay/pruning, extraction, cleanup
- Bidirectional mobility between core (≤20%) and regular memory tiers
- Core memories ranked by pure usage (no importance multiplier)

Based on ACT-R memory model and Ebbinghaus forgetting curve research.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Tarun Sukhani
e65d1deedd Sync adabot changes on top of origin/main
Includes:
- memory-neo4j: four-phase sleep cycle (dedup, decay, extraction, cleanup)
- memory-neo4j: full plugin implementation with hybrid search
- memory-lancedb: updates and benchmarks
- OpenSpec workflow skills and commands
- Session memory hooks
- Various CLI and config improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 17:56:38 +08:00
Peter Steinberger
7cfd0aed5f test: remove duplicate non-date negative-case assertion 2026-02-16 09:56:46 +00:00
Peter Steinberger
d611db8049 test: remove duplicate provider-prefix assertion variant 2026-02-16 09:55:44 +00:00
Peter Steinberger
3eb9c2105c test: remove duplicate date-suffix assertion variant 2026-02-16 09:54:56 +00:00
Peter Steinberger
9f6462bd56 test: trim duplicate latest-suffix assertion variant 2026-02-16 09:54:05 +00:00
Peter Steinberger
2d03473072 test: trim duplicate provider-prefix assertion in short-model tests 2026-02-16 09:52:16 +00:00
Peter Steinberger
dbcdcc5d19 test: remove duplicate positive template-variable detection case 2026-02-16 09:51:09 +00:00
Peter Steinberger
c4297a8d60 test: remove redundant no-provider short-model case 2026-02-16 09:49:58 +00:00
Peter Steinberger
deef9f91bf test: remove duplicate multi-variable template check case 2026-02-16 09:48:51 +00:00
Peter Steinberger
523193a91f test: remove duplicate static template-variable false case 2026-02-16 09:47:45 +00:00
Peter Steinberger
cd04385f9f test: remove redundant provider-plus-date model-name case 2026-02-16 09:46:44 +00:00
Peter Steinberger
82fa526bb0 test: remove duplicate undefined template-variable guard case 2026-02-16 09:45:51 +00:00
Peter Steinberger
3fb4a7eb53 test: remove duplicate hook-wake heartbeat empty-file case 2026-02-16 09:44:16 +00:00
Peter Steinberger
7a6928712b test: remove redundant explicit telegram heartbeat target case 2026-02-16 09:43:01 +00:00
Peter Steinberger
9b351fcbd8 test: remove duplicate whatsapp group heartbeat target case 2026-02-16 09:41:50 +00:00
Peter Steinberger
d3ddf893c2 test: remove redundant store-rotation integration prune case 2026-02-16 09:39:48 +00:00
Peter Steinberger
a597bd26d4 test: remove duplicate direct-enabled whatsapp ack variant 2026-02-16 09:37:42 +00:00
Peter Steinberger
6fa150a890 test: trim redundant whatsapp mention-true ack reaction case 2026-02-16 09:36:02 +00:00
Peter Steinberger
93ad783c1b test: remove redundant slash channel-policy integration case 2026-02-16 09:34:35 +00:00
Peter Steinberger
acc6b62289 test: remove low-value private-channel lookup slash edge case 2026-02-16 09:32:38 +00:00
Peter Steinberger
fec1566f04 test: remove duplicate ack-reaction none-scope branch case 2026-02-16 09:30:33 +00:00
Peter Steinberger
ced5148afd test: remove redundant identity emoji response-prefix case 2026-02-16 09:29:41 +00:00
Peter Steinberger
c0973f24c6 test: remove low-value concurrency passthrough unit case 2026-02-16 09:28:20 +00:00
Peter Steinberger
6a392b8493 test: trim redundant ack-reaction removeAfterReply guard case 2026-02-16 09:27:12 +00:00
Peter Steinberger
f8ae538985 test: remove low-value slash arg-menu payload-shape case 2026-02-16 09:25:34 +00:00
Peter Steinberger
b63f9b7066 test: remove redundant configured memory-flush prompt case 2026-02-16 09:23:03 +00:00
Peter Steinberger
4e3429ae6e test: remove low-value typing-without-consumer variant 2026-02-16 09:22:17 +00:00
Peter Steinberger
78976d3f6f test: remove low-value dm-fallback slash access edge case 2026-02-16 09:21:39 +00:00
Peter Steinberger
e30900f93e test: remove low-value deprecated pruneDays e2e mapping case 2026-02-16 09:20:40 +00:00
Peter Steinberger
cef02df9d5 test: remove redundant explicit-deny slash policy case 2026-02-16 09:19:55 +00:00
Peter Steinberger
0f7ad51020 test: remove low-signal malformed slash button edge case 2026-02-16 09:18:36 +00:00
Peter Steinberger
4e16893c61 test: remove low-value memory-flush ro workspace case 2026-02-16 09:17:47 +00:00
Peter Steinberger
192dbc3ba9 test: drop duplicate role-ordering exception rewrite case 2026-02-16 09:16:11 +00:00
Peter Steinberger
d0b0ca9fcf test: remove low-value open-policy slash channel case 2026-02-16 09:15:18 +00:00
Peter Steinberger
22c53af604 test: remove redundant saveSessionStore cap e2e case 2026-02-16 09:13:56 +00:00
Peter Steinberger
54948a1d44 test: remove redundant maintenance config mapping e2e case 2026-02-16 09:13:05 +00:00
Peter Steinberger
22a1a56e7e test: remove low-value maintenance defaults e2e assertion 2026-02-16 09:11:17 +00:00
Peter Steinberger
15f8c57797 test: speed up subagent announce e2e and drop duplicate defer case 2026-02-16 09:10:11 +00:00
Peter Steinberger
404a8bc35f test: remove redundant pruning-plus-capping e2e case 2026-02-16 09:07:24 +00:00
Peter Steinberger
7a4c131d6b test: remove low-value mirrored-text media-filename unit case 2026-02-16 09:05:38 +00:00
Peter Steinberger
b156aafab9 test: remove low-value direct metadata-mapping unit case 2026-02-16 09:04:20 +00:00
Peter Steinberger
838d875fcb test: remove low-value custom-root agent-extraction path case 2026-02-16 09:03:07 +00:00
Peter Steinberger
7932387df2 test: remove low-value stale-prune no-updatedAt edge case 2026-02-16 09:02:08 +00:00
Peter Steinberger
4d2ba58da5 test: remove low-value legacy dm-direct fallback permutation 2026-02-16 09:00:54 +00:00
Peter Steinberger
7d26eae3ee test: remove low-value no-updatedAt cap-priority edge case 2026-02-16 09:00:02 +00:00
Peter Steinberger
5dc02aa55e test: remove low-value concurrent store-entry merge permutation 2026-02-16 08:58:43 +00:00
Peter Steinberger
c8704297b2 test: remove low-value relative traversal session-file guard case 2026-02-16 08:57:45 +00:00
Peter Steinberger
eb7b5c02c3 test: remove low-value cross-storepath lock parallelism case 2026-02-16 08:56:28 +00:00
Peter Steinberger
314f193030 fix(ci): run scope detection on blacksmith runners 2026-02-16 09:56:11 +01:00
Peter Steinberger
d5bc5ab7ba test: remove low-value resolveStorePath tilde-expansion unit case 2026-02-16 08:54:55 +00:00
Peter Steinberger
fecd623431 test: remove duplicate reset precedence permutation case 2026-02-16 08:53:51 +00:00
Peter Steinberger
1e4cf489e0 fix(ci): keep main runs alive while coalescing newer pushes 2026-02-16 09:53:36 +01:00
Peter Steinberger
5d8f43ae8e test: remove duplicate explicit-agent fallback path case 2026-02-16 08:52:55 +00:00
Peter Steinberger
896f9efcb7 test: remove low-value absolute-in-dir session-file happy path 2026-02-16 08:51:41 +00:00
Peter Steinberger
f448e4bf77 test: remove low-value lock queue cleanup bookkeeping case 2026-02-16 08:50:59 +00:00
Peter Steinberger
ada7a6289f fix(ci): dedupe docker release runs by ref 2026-02-16 09:50:37 +01:00
Peter Steinberger
731d72e119 test: remove redundant in-dir relative session-file acceptance case 2026-02-16 08:49:41 +00:00
Peter Steinberger
bf801f5159 test: remove low-value unknown-session mirror guard case 2026-02-16 08:48:23 +00:00
Peter Steinberger
929a96c2f8 test: remove low-signal mirrored-text trim unit case 2026-02-16 08:47:45 +00:00
Peter Steinberger
2983ef0243 fix(ci): use ref-based concurrency across workflows 2026-02-16 09:47:07 +01:00
Peter Steinberger
b5183c93d6 test: remove low-value lock-storePath guard wrapper test 2026-02-16 08:46:49 +00:00
Peter Steinberger
bd0e7d3d22 test: remove low-value positive session-id validation case 2026-02-16 08:45:30 +00:00
Peter Steinberger
19dfdfe5a8 test: remove low-value missing-session-key mirror guard case 2026-02-16 08:44:46 +00:00
Peter Steinberger
2d6b605cc3 test: remove low-value session-file options wrapper assertion 2026-02-16 08:44:01 +00:00
Peter Steinberger
025d4152d1 fix(ci): key concurrency by ref instead of sha 2026-02-16 09:42:58 +01:00
Peter Steinberger
f9419e26bb test: remove duplicate empty-text mirror integration case 2026-02-16 08:42:38 +00:00
Peter Steinberger
a4f86dc433 test: remove low-value session-file options agent-only case 2026-02-16 08:41:46 +00:00
Peter Steinberger
0c035c85ab test: remove redundant single-error lock queue recovery case 2026-02-16 08:40:34 +00:00
Peter Steinberger
aabc09bb9b test: remove duplicate lock-queue cleanup success case 2026-02-16 08:39:43 +00:00
Peter Steinberger
0d2e13fb73 test: remove redundant transcript-path wrapper case 2026-02-16 08:38:18 +00:00
Peter Steinberger
4f05d045b9 test: remove duplicate absolute outside-session-path guard case 2026-02-16 08:37:19 +00:00
Peter Steinberger
3daaa19426 fix(ci): use JDK 17 for Android SDK setup 2026-02-16 09:36:54 +01:00
Peter Steinberger
ec00efb38d test: remove duplicate reset-by-type direct selection case 2026-02-16 08:36:30 +00:00
Peter Steinberger
83a5f7ba8c test: remove duplicate passthrough storePath guard case 2026-02-16 08:35:14 +00:00
Peter Steinberger
6a759c9191 test: remove duplicate empty-storePath guard case 2026-02-16 08:34:22 +00:00
Peter Steinberger
f6b7736744 test: remove redundant absolute topic-suffix session-file case 2026-02-16 08:33:33 +00:00
Ayaan Zaidi
b6a9741ba4 refactor(telegram): simplify send/dispatch/target handling (#17819)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: fcb7aeeca3
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 14:00:34 +05:30
Peter Steinberger
1f607bec49 test: remove low-value no-rotation file-size case 2026-02-16 08:24:46 +00:00
Peter Steinberger
3dbb69da05 test: remove duplicate session file options fallback case 2026-02-16 08:23:52 +00:00
Peter Steinberger
49d383ba7c test: remove redundant default-root explicit fallback case 2026-02-16 08:23:09 +00:00
Peter Steinberger
e72e8ebe62 test: remove redundant default-root extracted fallback case 2026-02-16 08:22:23 +00:00
Peter Steinberger
374ad8c813 test: remove redundant all-stale pruning case 2026-02-16 08:20:51 +00:00
Peter Steinberger
6f4da72cb5 test: remove redundant cap-entry default-fallback case 2026-02-16 08:19:06 +00:00
Peter Steinberger
facf53cc3f test: remove redundant stale-prune default-fallback case 2026-02-16 08:17:51 +00:00
Peter Steinberger
eaec65656f test: remove redundant cap-entry under-limit case 2026-02-16 08:16:34 +00:00
Peter Steinberger
dfaca933c6 test: remove redundant rotate timestamp case 2026-02-16 08:15:20 +00:00
Peter Steinberger
aaf308d7ec test: remove redundant stale-prune under-limit case 2026-02-16 08:13:57 +00:00
Peter Steinberger
d115d48a72 test: remove redundant rotate missing-file no-op case 2026-02-16 08:12:38 +00:00
Peter Steinberger
d174c38737 test: remove redundant stale-prune boundary case 2026-02-16 08:11:40 +00:00
Peter Steinberger
005dbdd13e test: remove redundant stale-prune empty-store case 2026-02-16 08:09:56 +00:00
Peter Steinberger
c31e33cd18 test: remove redundant stale-prune return-count case 2026-02-16 08:08:59 +00:00
Peter Steinberger
f4fbfae97e test: remove redundant cap-entry empty-store case 2026-02-16 08:07:35 +00:00
Peter Steinberger
f349d40e62 test: remove redundant cap-entry return-count case 2026-02-16 08:06:41 +00:00
Peter Steinberger
d71779b46f test: remove redundant session-rotation exact-limit case 2026-02-16 08:05:26 +00:00
Peter Steinberger
df062fdb63 test: remove redundant cap-entry exact-limit case 2026-02-16 08:04:24 +00:00
Peter Steinberger
52ddaed795 test: remove redundant elide exact-limit case 2026-02-16 08:03:02 +00:00
Peter Steinberger
b7a20d8e8d test: remove redundant depth-1 subagent session case 2026-02-16 08:02:10 +00:00
Peter Steinberger
5cb228fdd0 test: remove redundant quick-reply truncation case 2026-02-16 08:00:47 +00:00
Peter Steinberger
8fd6d4d6dd test: remove redundant messageAction default-text case 2026-02-16 07:59:19 +00:00
Peter Steinberger
242e8f5c43 test: remove low-signal line account listing coverage 2026-02-16 07:58:00 +00:00
Peter Steinberger
4aab640fd1 test: remove redundant default-account normalization case 2026-02-16 07:56:41 +00:00
Peter Steinberger
35b6ccd62c test: remove redundant rich-menu action passthrough case 2026-02-16 07:55:39 +00:00
Peter Steinberger
7e1f542233 test: remove redundant uriAction passthrough case 2026-02-16 07:54:37 +00:00
Peter Steinberger
5927c53630 test: remove redundant postback displayText passthrough case 2026-02-16 07:53:10 +00:00
Peter Steinberger
8505577218 test: remove redundant line account-id env listing case 2026-02-16 07:52:08 +00:00
Peter Steinberger
b18b85dc77 test: remove redundant default rich-menu command smoke case 2026-02-16 07:51:04 +00:00
Peter Steinberger
f3eb003db9 test: remove redundant quick-reply creation smoke case 2026-02-16 07:50:02 +00:00
Peter Steinberger
0448693f8f test: remove redundant messageAction passthrough case 2026-02-16 07:49:15 +00:00
Peter Steinberger
e86647889c test: remove redundant datetimepicker passthrough case 2026-02-16 07:47:36 +00:00
Peter Steinberger
993a5e63a1 test: remove redundant yes-no label passthrough case 2026-02-16 07:46:48 +00:00
Peter Steinberger
c01e97f124 test: remove redundant list-card action passthrough case 2026-02-16 07:45:09 +00:00
Peter Steinberger
bf2d78505e test: remove redundant notification title passthrough case 2026-02-16 07:43:03 +00:00
Peter Steinberger
91337b4b6f test: remove redundant confirm alt-text passthrough case 2026-02-16 07:42:09 +00:00
Peter Steinberger
f74d56bd3b test: remove redundant image-card aspect ratio passthrough case 2026-02-16 07:40:45 +00:00
Peter Steinberger
56d0ad6942 test: remove redundant action-card hero passthrough case 2026-02-16 07:39:50 +00:00
Peter Steinberger
5997a4b0ef test: remove redundant media-player image passthrough case 2026-02-16 07:39:01 +00:00
Peter Steinberger
720aa3c1e6 test: drop redundant line info-card default footer case 2026-02-16 07:37:41 +00:00
Peter Steinberger
223e2a7127 test: remove redundant button thumbnail passthrough case 2026-02-16 07:36:45 +00:00
Peter Steinberger
31ab8ad46d test: remove overlapping short line grid layout case 2026-02-16 07:35:16 +00:00
Peter Steinberger
82a8fc0bc7 test: remove redundant yes-no default template case 2026-02-16 07:34:18 +00:00
Peter Steinberger
227e31d791 test: remove redundant line default menu config case 2026-02-16 07:32:30 +00:00
Peter Steinberger
357b1e8fee test: remove duplicate line account listing case 2026-02-16 07:30:37 +00:00
Peter Steinberger
4c46c23ca8 test: remove redundant default line account id case 2026-02-16 07:29:10 +00:00
Peter Steinberger
189b2e0588 test: remove redundant line default-menu bounds case 2026-02-16 07:28:02 +00:00
Peter Steinberger
a39c2263e5 test: prune overlapping line markdown conversion cases 2026-02-16 07:26:43 +00:00
Peter Steinberger
0490d0e173 test: drop redundant product carousel limit case 2026-02-16 07:25:16 +00:00
Peter Steinberger
64a0339d58 test: trim redundant line quick-reply account checks 2026-02-16 07:23:40 +00:00
Peter Steinberger
077130bdb8 test: remove overlapping line webhook/account cases 2026-02-16 07:22:30 +00:00
Peter Steinberger
12d6b3b0c9 test: prune redundant line action-type checks 2026-02-16 07:20:57 +00:00
Peter Steinberger
3028a1bd3e test: remove redundant line template type assertions 2026-02-16 07:19:41 +00:00
Peter Steinberger
57e055ddb5 test: remove line text quick-reply passthrough tests 2026-02-16 07:17:39 +00:00
Peter Steinberger
4fd008e918 test: remove redundant flex message wrapper test 2026-02-16 07:16:46 +00:00
Peter Steinberger
d39b8541f8 test: prune redundant markdown extractor plain-text negatives 2026-02-16 07:15:47 +00:00
Peter Steinberger
ac4183edd7 test: remove redundant line existence assertions 2026-02-16 07:14:54 +00:00
Peter Steinberger
838963d66c test: drop low-signal line media player footer assertion 2026-02-16 07:13:47 +00:00
Peter Steinberger
4852dd4503 test: remove duplicate line flex wrapper coverage 2026-02-16 07:12:52 +00:00
Peter Steinberger
4d1cb661fc test: remove redundant line link menu wrapper test 2026-02-16 07:11:16 +00:00
Peter Steinberger
3bd961f00a test: drop duplicate line quick-reply wrapper assertion 2026-02-16 07:10:19 +00:00
Peter Steinberger
583345fdfe test: collapse redundant markdown conversion micro-tests 2026-02-16 07:09:31 +00:00
Peter Steinberger
3d550ed4c3 test: remove low-signal line card existence tests 2026-02-16 07:08:32 +00:00
Peter Steinberger
c37cc5ffad test: trim redundant markdown strip and table layout checks 2026-02-16 07:07:07 +00:00
Peter Steinberger
b83ccfba13 test: remove redundant line flex baseline checks 2026-02-16 07:04:56 +00:00
Peter Steinberger
8ea890e8fb test: remove duplicate line quick-reply assertions 2026-02-16 07:03:51 +00:00
Peter Steinberger
ae6060d777 test: remove redundant line markdown conversion smoke checks 2026-02-16 07:02:37 +00:00
Peter Steinberger
ec708b6ab5 test: trim redundant line action helper smoke checks 2026-02-16 07:01:43 +00:00
Peter Steinberger
944a32cf02 test: remove redundant line flex smoke checks 2026-02-16 06:59:46 +00:00
Peter Steinberger
c4880675e1 test: prune redundant line template constructor checks 2026-02-16 06:58:33 +00:00
Peter Steinberger
8b6537d857 test: trim redundant line template shape checks 2026-02-16 06:57:15 +00:00
Peter Steinberger
12c3821acb test: prune low-signal line flex template checks 2026-02-16 06:55:49 +00:00
Peter Steinberger
a69c06e3cc test: remove duplicate daemon profile trim wrappers 2026-02-16 06:53:13 +00:00
Peter Steinberger
67aa7eefe5 test: remove redundant sticker thread id assertion 2026-02-16 06:51:50 +00:00
Peter Steinberger
425c715a05 test: remove duplicate sticker recipient normalization checks 2026-02-16 06:50:44 +00:00
Peter Steinberger
dcba3e5699 test: trim redundant telegram thread+reply combination checks 2026-02-16 06:49:17 +00:00
Peter Steinberger
27083e6f1a test: remove redundant telegram requireMention negative case 2026-02-16 06:47:45 +00:00
Peter Steinberger
18bb242316 test: remove duplicate line action creator coverage 2026-02-16 06:46:21 +00:00
the sun gif man
68ea063958 🤖 fix: preserve openai reasoning replay ids (#17792)
What:
- disable tool-call id sanitization for OpenAI/OpenAI Codex transcript policy
- gate id sanitization in image sanitizer to full mode only
- keep orphan reasoning downgrade scoped to OpenAI model-switch replay path
- update transcript policy, session-history, sanitizer, and reasoning replay tests
- document OpenAI model-switch orphan-reasoning cleanup behavior in transcript hygiene reference

Why:
- OpenAI Responses replay depends on canonical call_id|fc_id pairings for reasoning followers
- strict id rewriting in OpenAI path breaks follower matching and triggers rs_* orphan 400s
- limiting scope avoids behavior expansion while fixing the identified regression

Tests:
- pnpm vitest run src/agents/transcript-policy.test.ts src/agents/pi-embedded-runner.sanitize-session-history.test.ts src/agents/openai-responses.reasoning-replay.test.ts
- pnpm vitest run --config vitest.e2e.config.ts src/agents/transcript-policy.e2e.test.ts src/agents/pi-embedded-runner.sanitize-session-history.e2e.test.ts src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.e2e.test.ts src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts
- pnpm lint
- pnpm format:check
- pnpm check:docs
- pnpm test (fails in current macOS bash 3.2 env at test/git-hooks-pre-commit.integration.test.ts: mapfile not found)
2026-02-15 22:45:01 -08:00
Peter Steinberger
eefda1314f test: drop duplicate telegram username allowFrom check 2026-02-16 06:44:38 +00:00
Peter Steinberger
a8a22920f1 test: remove duplicate telegram allowFrom cases 2026-02-16 06:43:24 +00:00
Peter Steinberger
a8084b24d6 test: trim additional low-signal flex template checks 2026-02-16 06:40:26 +00:00
Peter Steinberger
97d5ff3500 test: remove low-signal flex template option-only assertions 2026-02-16 06:38:41 +00:00
Peter Steinberger
abb7618b0f test: remove pass-through rich menu action mode checks 2026-02-16 06:37:38 +00:00
Peter Steinberger
1ec0f3b81d test: drop redundant daemon profile normalization wrappers 2026-02-16 06:36:15 +00:00
Peter Steinberger
6c3e7896c5 test: remove duplicate lowercase default profile daemon path cases 2026-02-16 06:34:05 +00:00
Peter Steinberger
2a5fa426f2 test: remove redundant schtasks command parsing cases 2026-02-16 06:32:59 +00:00
Peter Steinberger
29203884c2 test: consolidate gateway profile normalization coverage 2026-02-16 06:31:36 +00:00
Peter Steinberger
91e120870f test: remove duplicate uppercase default profile daemon cases 2026-02-16 06:29:20 +00:00
Peter Steinberger
6a9ead3813 test: remove duplicate profile-specific daemon constant cases 2026-02-16 06:28:15 +00:00
Peter Steinberger
cb998aa7f9 test: remove duplicate systemd exec-start split assertion 2026-02-16 06:27:19 +00:00
Peter Steinberger
ac02e45a88 test: drop redundant empty-profile extraction cases 2026-02-16 06:25:18 +00:00
Peter Steinberger
8f603ec03d test: remove duplicate default-profile casing checks 2026-02-16 06:23:34 +00:00
Peter Steinberger
84e0ee3c31 test: remove duplicate uppercase default profile case 2026-02-16 06:22:04 +00:00
Peter Steinberger
da2bdbef7e test: remove duplicate systemd exec-start split case 2026-02-16 06:21:13 +00:00
Peter Steinberger
1be7c4ba8e test: move session store pruning integration suite to e2e lane 2026-02-16 06:19:49 +00:00
Peter Steinberger
a82df1015b test: remove duplicate hr spacing assertion 2026-02-16 06:16:33 +00:00
Peter Steinberger
a0b459b8f9 test: remove duplicate undefined-profile default cases 2026-02-16 06:15:26 +00:00
Peter Steinberger
28118ca051 test: drop duplicate internal hook lifecycle case 2026-02-16 06:14:23 +00:00
Peter Steinberger
d374a64658 test: move skills-cli integration coverage to e2e lane 2026-02-16 06:13:46 +00:00
Peter Steinberger
0895bb6de6 test: move skills-install fallback suite to e2e lane 2026-02-16 06:11:01 +00:00
Peter Steinberger
189cba0100 test: remove duplicate sandbox access memory-flush case 2026-02-16 06:08:44 +00:00
Peter Steinberger
108ebc380f test: remove duplicate registry throw assertion 2026-02-16 06:06:24 +00:00
Peter Steinberger
93e62d8e3e test: remove duplicate slack dm authorization case 2026-02-16 06:04:58 +00:00
Peter Steinberger
ed28ad2822 test: remove duplicate configured canonical key case 2026-02-16 06:03:09 +00:00
Peter Steinberger
00bbddeef5 test: move git hook regression to e2e lane 2026-02-16 06:01:07 +00:00
Peter Steinberger
5ac59e6e02 test: remove duplicate availability-unavailable fallback case 2026-02-16 05:58:49 +00:00
Peter Steinberger
bfb5a44089 test: speed up plugin optional tools suite 2026-02-16 05:56:26 +00:00
Peter Steinberger
599195fb31 test: trim duplicate antigravity availability case 2026-02-16 05:53:54 +00:00
Peter Steinberger
705d83aec7 test: drop duplicate z-ai alias filter case 2026-02-16 05:41:58 +00:00
Peter Steinberger
c80017e704 test: trim duplicate z.ai provider alias case 2026-02-16 05:39:42 +00:00
Peter Steinberger
9e67f9d889 test: remove duplicate invalid slash-button case 2026-02-16 05:36:55 +00:00
Peter Steinberger
9383f85046 test: trim redundant web media prefix coverage 2026-02-16 05:34:08 +00:00
Peter Steinberger
5212d1c79e test: make sandbox symlink-escape assertion platform-aware 2026-02-16 06:26:08 +01:00
Peter Steinberger
7aa7b04fb0 test: rebalance isolated unit test lane 2026-02-16 05:22:00 +00:00
Peter Steinberger
b3d3f36360 test: speed up slack slash monitor tests 2026-02-16 05:20:22 +00:00
Peter Steinberger
2b6f8548c9 test: trim pre-commit hook integration setup 2026-02-16 05:15:55 +00:00
Peter Steinberger
9684ae4c6d test: tighten process timeout thresholds with stabilized emit guard 2026-02-16 05:09:47 +00:00
Peter Steinberger
39fa81dc96 chore: bump version to 2026.2.16 2026-02-16 06:08:47 +01:00
Peter Steinberger
f1654b4ba2 test: isolate telegram bot behavior suite from unit-fast lane 2026-02-16 04:50:19 +00:00
Peter Steinberger
0b780789bc test: further reduce process timeout waits in fast suites 2026-02-16 04:48:55 +00:00
Peter Steinberger
795874711b test: shorten process timeout waits in exec and supervisor suites 2026-02-16 04:45:44 +00:00
Peter Steinberger
17d8e2a1c8 test: reduce supervisor no-output wait threshold 2026-02-16 04:43:33 +00:00
Peter Steinberger
c53e4e6c8f test: trim exec timeout waits for faster suite runtime 2026-02-16 04:41:45 +00:00
Peter Steinberger
4f5bc0a493 chore(release): align 2026.2.15 metadata 2026-02-16 05:38:58 +01:00
Peter Steinberger
92ec3ddc14 test: drop brittle pre-commit script structure test 2026-02-16 04:35:54 +00:00
Peter Steinberger
510889d439 test: isolate slack slash and telegram bootstrap suites 2026-02-16 04:34:51 +00:00
Peter Steinberger
ceddb4a593 style(memory): format flaky ci test files 2026-02-16 05:32:42 +01:00
Peter Steinberger
794808b169 test: isolate hook installer suite from unit-fast lane 2026-02-16 04:31:30 +00:00
Peter Steinberger
fb6dba2058 fix(ci): align tar override and lockfile to 7.5.9 2026-02-16 05:30:02 +01:00
Varun Kruthiventi
c62b90a2b7 fix(telegram): stop block streaming from splitting messages when streamMode is off (#17704)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 847162caad
Co-authored-by: saivarunk <2976867+saivarunk@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-16 09:57:29 +05:30
Peter Steinberger
1b223dbdd8 test: isolate git-hooks integration and stabilize exec timeout 2026-02-16 04:24:00 +00:00
Peter Steinberger
e7ccbd1445 test: isolate block-streaming suite from unit-fast lane 2026-02-16 04:20:21 +00:00
Peter Steinberger
bc65e787c8 test: trim process no-output timeout waits 2026-02-16 04:17:38 +00:00
Vignesh Natarajan
3e1986f119 chore (changelog): note qmd collection isolation fix 2026-02-15 20:15:12 -08:00
Vignesh Natarajan
b32ae6fa0c fix (memory/qmd): isolate managed collections per agent 2026-02-15 20:14:45 -08:00
Peter Steinberger
5d436f48b2 docs: add mac beta release runbook note 2026-02-16 05:13:49 +01:00
Peter Steinberger
90800cd23e chore(release): update appcast for 2026.2.15 2026-02-16 05:10:59 +01:00
Peter Steinberger
f52805a783 test: reuse heartbeat suite fixtures across cases 2026-02-16 04:10:51 +00:00
Peter Steinberger
a7385aa8ac test: reduce process timeout test latency 2026-02-16 04:08:50 +00:00
Peter Steinberger
e8a50e41a5 test: reuse fixtures in skills install fallback suite 2026-02-16 04:03:24 +00:00
Peter Steinberger
83ce48302f test: trim timeout-heavy exec and telegram cases 2026-02-16 04:00:53 +00:00
Peter Steinberger
25dc4293bf test: speed up isolated-agent and pty test suites 2026-02-16 03:58:43 +00:00
Peter Steinberger
3fe22ea2fd chore(release): align .15 changelog ordering and release notes 2026-02-16 04:50:24 +01:00
Peter Steinberger
31939397a9 test: optimize hot-path test runtime 2026-02-16 03:49:05 +00:00
Ayaan Zaidi
9b2e1769c5 docs(contributing): update maintainers list (#17719)
* docs(contributing): refresh maintainer list

* docs(contributing): fix tyler x handle

* docs(contributing): add discord admin scope for shadow

* docs(contributing): add irc scope for vignesh
2026-02-16 09:18:35 +05:30
Peter Steinberger
61a865031f fix: sync pnpm lockfile for docker onboard 2026-02-16 04:45:42 +01:00
Peter Steinberger
ae6fe67550 test: align e2e coverage with supervisor session flow 2026-02-16 03:41:58 +00:00
Peter Steinberger
702b94fe8f style(line): format files to unblock ci check 2026-02-16 03:39:41 +00:00
Peter Steinberger
b5a63e18f9 test(sandbox): add array-order hash and recreate regression tests 2026-02-16 04:36:24 +01:00
Vignesh Natarajan
78277152ca test(heartbeat): cover telegram showOk suppression 2026-02-15 19:35:25 -08:00
Peter Steinberger
d1fca442b4 refactor(sandbox): centralize sha256 helpers 2026-02-16 04:33:47 +01:00
Sebastian
3c467baa2d test(skills): add status-to-install apt fallback coverage 2026-02-15 22:32:51 -05:00
Sebastian
c8e110e2e3 refactor(skills): extract installer strategy helpers 2026-02-15 22:32:51 -05:00
Peter Steinberger
41ded303b4 fix(sandbox): preserve array order in config hashing 2026-02-16 04:32:03 +01:00
Vignesh Natarajan
cbf58d2e1c fix(memory): harden context window cache collisions 2026-02-15 19:31:52 -08:00
Peter Steinberger
559c8d9930 fix: replace deprecated SHA-1 in sandbox config hash 2026-02-16 04:30:59 +01:00
Peter Steinberger
aef1d55300 fix(cron): normalize skill-filter snapshots and split isolated run helpers 2026-02-16 04:27:12 +01:00
Peter Steinberger
6754a926ee fix(pairing): support legacy telegram allowFrom migration 2026-02-16 03:26:07 +00:00
Vignesh Natarajan
18c6f40d32 chore (changelog): credit LINE webhook fail-closed hardening 2026-02-15 19:25:33 -08:00
Vignesh Natarajan
c7bc7249c3 test (security/line): cover missing webhook auth startup paths 2026-02-15 19:25:33 -08:00
Vignesh Natarajan
beb77229c0 fix (security/line): fail closed when webhook auth is missing 2026-02-15 19:25:33 -08:00
McRolly NWANGWU
d19b746928 feat(skills): add cross-platform install fallback for non-brew environments (#17687)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 3ed4850838
Co-authored-by: mcrolly <60803337+mcrolly@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-15 22:25:26 -05:00
Vignesh Natarajan
9df21da129 chore (changelog): credit memory flush runtime date fix 2026-02-15 19:20:38 -08:00
Vignesh Natarajan
3087657679 test (memory/compaction): cover resolved memory flush prompt semantics 2026-02-15 19:20:38 -08:00
Vignesh Natarajan
ffbcb37342 fix (memory/compaction): inject runtime date-time into memory flush prompt 2026-02-15 19:20:38 -08:00
Shadow
a61c2dc4bd Discord: add component v2 UI tool support (#17419) 2026-02-15 21:19:25 -06:00
Peter Steinberger
b4a9eacd76 chore: format qmd-manager test 2026-02-16 04:18:42 +01:00
Peter Steinberger
ac2ede5bb1 fix(telegram): treat no-op editMessage as success 2026-02-16 04:18:24 +01:00
Vignesh Natarajan
7089885ac4 chore (changelog): credit unicode FTS tokenization fix 2026-02-15 19:17:06 -08:00
Vignesh Natarajan
501e893676 fix (memory/search): support unicode tokens in FTS query builder 2026-02-15 19:17:03 -08:00
Vignesh Natarajan
82631d225c chore (changelog): credit sandbox prompt path guidance fix 2026-02-15 19:16:02 -08:00
Vignesh Natarajan
799049f586 fix (agents/sandbox): clarify container-vs-host workspace paths in prompt 2026-02-15 19:16:02 -08:00
Peter Steinberger
ab1dc89a2d chore(deps): update dependencies 2026-02-16 04:15:03 +01:00
Vignesh Natarajan
0b3d4b8e57 chore (changelog): credit control-ui scope bypass fix 2026-02-15 19:12:10 -08:00
Vignesh Natarajan
eed02a2b57 fix (security/gateway): preserve control-ui scopes in bypass mode 2026-02-15 19:12:06 -08:00
Vignesh Natarajan
a203430aa3 chore (changelog): credit pairing account isolation fix 2026-02-15 19:10:06 -08:00
Vignesh Natarajan
6cf7c02d4a feat (cli): add account selector for pairing commands 2026-02-15 19:10:06 -08:00
Vignesh Natarajan
6957354d48 fix (telegram/whatsapp): use account-scoped pairing allowlists 2026-02-15 19:10:06 -08:00
Vignesh Natarajan
ee10feb80e fix (security/pairing): scope pairing stores by account 2026-02-15 19:10:06 -08:00
Marcus Castro
61c9935264 fix: correct indentation in cron isolated-agent run.ts 2026-02-16 04:09:39 +01:00
Marcus Castro
e5dbfde7e1 test(cron): add empty-skills edge case for skill filter coverage
Addresses Greptile review feedback: locks in behavior when an agent
has skills: [] (explicit empty list), ensuring skillFilter: [] is
forwarded to buildWorkspaceSkillSnapshot to filter out all skills.
2026-02-16 04:09:39 +01:00
Marcus Castro
053affffec fix(cron): pass agent-level skill filter to isolated cron sessions
Isolated cron sessions called buildWorkspaceSkillSnapshot without
the skillFilter parameter, causing all skills to be included even
when an agent had a restricted skills list via agents.list[].skills.

Resolves the filter using resolveAgentSkillsFilter and passes it
through, aligning isolated cron with main session behavior.

Fixes #10804
2026-02-16 04:09:39 +01:00
Peter Steinberger
e1e46dc11b docs: reorder 2026.2.15 changelog entries by impact 2026-02-16 04:06:46 +01:00
Peter Steinberger
7cd288a8a0 docs: add plugin release fast path notes 2026-02-16 04:06:09 +01:00
Peter Steinberger
38ac4b8083 test(pty): stabilize non-windows signal assertion 2026-02-16 03:06:03 +00:00
Vignesh Natarajan
e7a053b4dd chore (changelog): credit qmd session collection rebind fix 2026-02-15 19:03:59 -08:00
Vignesh Natarajan
85430c8495 fix (memory/qmd): rebind drifted managed collection paths 2026-02-15 19:03:55 -08:00
Vignesh Natarajan
8e162d9319 chore (changelog): credit inbound metadata id fix 2026-02-15 19:01:08 -08:00
Vignesh Natarajan
bed8e7abe6 fix (auto-reply): expose inbound message identifiers in trusted metadata 2026-02-15 19:01:08 -08:00
Peter Steinberger
82333add95 test(sessions): cover sandbox session-tools context 2026-02-16 03:00:25 +00:00
Peter Steinberger
7a4a068124 test(sessions): add access and resolution helper coverage 2026-02-16 02:59:30 +00:00
Peter Steinberger
1a03aad246 refactor(sessions): split access and resolution helpers 2026-02-16 03:56:49 +01:00
Peter Steinberger
2f621876f1 test(gateway): cover basePath bootstrap config endpoint 2026-02-16 02:56:23 +00:00
Peter Steinberger
6dfefa1be1 test(ui): cover trailing-slash bootstrap basePath 2026-02-16 02:55:24 +00:00
Peter Steinberger
c876d24d89 test: expand prompt and update hint coverage 2026-02-16 02:54:06 +00:00
Tag
6802b155a8 fix: stop LLM retry loop when browser control service is unavailable (#17673)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 90f47fe132
Co-authored-by: tag-assistant <260167501+tag-assistant@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
2026-02-15 21:53:49 -05:00
Peter Steinberger
17a148c8a8 fix: always include long-wait polling guidance in prompt 2026-02-16 03:51:38 +01:00
Peter Steinberger
abd26b6e54 refactor(ui): reuse Control UI bootstrap path constant 2026-02-16 03:50:39 +01:00
Peter Steinberger
8985f23de7 test(gateway): move Control UI http coverage 2026-02-16 03:50:39 +01:00
Peter Steinberger
c6e6023e3a refactor(gateway): share Control UI bootstrap contract and CSP 2026-02-16 03:50:39 +01:00
Peter Steinberger
6e7c1c16e7 test: remove duplicate legacy sessions_spawn e2e file 2026-02-16 03:48:51 +01:00
Peter Steinberger
52e240d10d test(status): add coverage for update summary + timestamps 2026-02-16 02:47:47 +00:00
Peter Steinberger
b6305e9725 test(skills): split installer security coverage 2026-02-16 03:47:28 +01:00
Peter Steinberger
2363e1b085 fix(security): restrict skill download target paths 2026-02-16 03:47:28 +01:00
Peter Steinberger
c6c53437f7 fix(security): scope session tools and webhook secret fallback 2026-02-16 03:47:10 +01:00
Peter Steinberger
fbe6d7c701 ci: include a2ui sources in onboarding docker build 2026-02-16 02:45:00 +00:00
Peter Steinberger
2a53eff856 perf: speed up slack slash handler tests 2026-02-16 02:45:00 +00:00
Peter Steinberger
cd37c52624 perf: speed up slack slash tests 2026-02-16 02:45:00 +00:00
Peter Steinberger
17e5a5015c perf: avoid async cron timer callbacks 2026-02-16 02:45:00 +00:00
Peter Steinberger
8515ae6eea perf: consolidate telegram bot test harness 2026-02-16 02:45:00 +00:00
Peter Steinberger
d1de66b6cf perf: speed up gateway lock tests 2026-02-16 02:45:00 +00:00
Peter Steinberger
7eeba3de85 perf: speed up telegram bot suite setup 2026-02-16 02:45:00 +00:00
Peter Steinberger
38c91c5a13 test: speed up skills-cli integration 2026-02-16 02:45:00 +00:00
Peter Steinberger
88033002ba test: consolidate nodes screen helpers 2026-02-16 02:45:00 +00:00
Peter Steinberger
f835301aed test: consolidate channel helper suites 2026-02-16 02:45:00 +00:00
Peter Steinberger
4d4f693f92 test: consolidate media store header extension coverage 2026-02-16 02:45:00 +00:00
Peter Steinberger
d95be2c384 fix: preserve sandbox allow-all semantics 2026-02-16 02:45:00 +00:00
Peter Steinberger
014d45f7ee test: tighten relay smoke + slack token validation 2026-02-16 02:45:00 +00:00
Peter Steinberger
4d9e310dad test: strengthen ports, tool policy, and note wrapping 2026-02-16 02:45:00 +00:00
Peter Steinberger
f50e1e8015 perf(test): fold gateway discover tests into run-loop 2026-02-16 02:45:00 +00:00
Peter Steinberger
aa1e4962da perf(test): fold doctor legacy migration harness cases 2026-02-16 02:45:00 +00:00
Peter Steinberger
ea07d3fdd8 perf(test): consolidate auth/pty/health mini suites 2026-02-16 02:45:00 +00:00
Peter Steinberger
f142048293 perf(test): fold tool-policy + doctor workspace entrypoints 2026-02-16 02:45:00 +00:00
Peter Steinberger
5fe47e7be6 perf(test): fold ports + terminal note suites 2026-02-16 02:45:00 +00:00
Peter Steinberger
3f44ea244f perf(test): fold health + signal mention tests 2026-02-16 02:45:00 +00:00
Peter Steinberger
6b2f40652f perf(test): consolidate daemon test entrypoints 2026-02-16 02:45:00 +00:00
Peter Steinberger
00e79ac897 perf(test): consolidate pi-embedded helpers e2e suites 2026-02-16 02:45:00 +00:00
Peter Steinberger
efe530acdd perf(test): fold session key utils into routing session key suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
d69496c449 perf(test): fold small config suites into config misc 2026-02-16 02:45:00 +00:00
Peter Steinberger
9386075b7b perf(test): fold node-host runner tests into sanitize env suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
e64dd7b56a perf(test): fold markdown list spacing into nested lists suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
0e4eada580 perf(test): fold telegram update offset store into token suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
65b5dbd6c1 perf(test): fold telegram sent-message cache tests into send suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
e770728cb5 perf(test): fold telegram download tests into fetch suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
cb80901cf9 perf(test): fold cron system event filter into system events suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
3e0076c9ce perf(test): drop redundant index entrypoint tests 2026-02-16 02:45:00 +00:00
Peter Steinberger
02124094bf perf(test): fold acp event mapper tests into client suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
37f030a671 perf(test): fold console prefix tests into logger suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
be3c431011 perf(test): fold gateway config schema tests into config misc 2026-02-16 02:45:00 +00:00
Peter Steinberger
b97c5d6158 perf(test): fold sender identity checks into channel config suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
2b4ebcb570 chore: remove accidental a2ui bundle artifacts 2026-02-16 02:45:00 +00:00
Peter Steinberger
2acc0b0f47 perf(test): fold globals unit tests into logger suite 2026-02-16 02:45:00 +00:00
Peter Steinberger
056070c2bf perf(test): fold cron webhook schema coverage into config misc 2026-02-16 02:44:59 +00:00
Peter Steinberger
04004c5663 perf(test): consolidate models-config provider unit tests 2026-02-16 02:44:59 +00:00
Peter Steinberger
e1350ff976 perf(test): fold imessage rpc client guard into targets suite 2026-02-16 02:44:59 +00:00
Peter Steinberger
8d2029a03d perf(test): fold qr-image tests into web login suite 2026-02-16 02:44:59 +00:00
Peter Steinberger
58ab60c0fc perf(test): fold tls fingerprint normalization into ssrf suite 2026-02-16 02:44:59 +00:00
Peter Steinberger
7c27c2d659 refactor(daemon-cli): share status text styling 2026-02-16 02:42:55 +00:00
Peter Steinberger
c1655982d4 refactor: centralize pre-commit file filtering 2026-02-16 03:42:11 +01:00
Peter Steinberger
91c49dd0ea refactor(status): share registry summary formatting 2026-02-16 02:41:30 +00:00
Peter Steinberger
8eecf97cc5 refactor(cli): share gmail webhook option parsing 2026-02-16 02:39:55 +00:00
Peter Steinberger
46e714058c refactor(subagents): dedupe list row builder 2026-02-16 02:38:00 +00:00
Peter Steinberger
1547bb6a07 refactor(auto-reply): share abort persistence 2026-02-16 02:36:18 +00:00
Peter Steinberger
0c8bb361ca refactor(gateway-tool): share write metadata parsing 2026-02-16 02:36:18 +00:00
seewhy
ddcc7a1a5d fix(discord): dedupe native skill commands by skillName (#17365)
* fix(discord): dedupe native skill commands by skill name

* Changelog: credit Discord skill dedupe

---------

Co-authored-by: yume <yume@yumedeMacBook-Pro.local>
Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-15 20:33:51 -06:00
Peter Steinberger
d9d5b53b42 refactor(logging): share local iso timestamp format 2026-02-16 02:32:59 +00:00
Peter Steinberger
9805ce0097 refactor(memory): reuse cached embedding collector 2026-02-16 02:32:59 +00:00
Peter Steinberger
cf69907015 fix(security): redact Telegram bot tokens in errors 2026-02-16 03:30:53 +01:00
Shakker
09566b1693 fix(discord): preserve channel session keys via channel_id fallbacks (#17622)
* fix(discord): preserve channel session keys via channel_id fallbacks

* docs(changelog): add discord session continuity note

* Tests: cover discord channel_id fallback

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-15 20:30:17 -06:00
Peter Steinberger
39d5590230 refactor(line): reuse reply chunk deps type 2026-02-16 02:29:07 +00:00
Peter Steinberger
35c5d2be5c refactor(telegram): share group allowFrom resolution 2026-02-16 02:27:01 +00:00
Peter Steinberger
b88f377623 fix: make fast-tool stub type portable 2026-02-16 03:23:45 +01:00
Peter Steinberger
ba84b12535 fix: harden pre-commit hook against option injection 2026-02-16 03:23:45 +01:00
507 changed files with 37295 additions and 10454 deletions

View File

@@ -6,14 +6,14 @@ on:
pull_request:
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
@@ -33,7 +33,7 @@ jobs:
changed-scope:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
@@ -53,11 +53,17 @@ jobs:
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
else
BASE="${{ github.event.pull_request.base.sha }}"
# pull_request runs use a merge commit checkout. Diffing parent branches is
# more reliable than relying on base SHA availability in rerun attempts.
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 && git rev-parse --verify HEAD^2 >/dev/null 2>&1; then
CHANGED="$(git diff --name-only HEAD^1...HEAD^2 2>/dev/null || echo "UNKNOWN")"
else
BASE="${{ github.event.pull_request.base.sha }}"
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
fi
fi
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
# Fail-safe: run broad checks if detection fails.
echo "run_node=true" >> "$GITHUB_OUTPUT"
@@ -672,7 +678,8 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3

View File

@@ -13,6 +13,10 @@ on:
- ".agents/**"
- "skills/**"
concurrency:
group: docker-release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

View File

@@ -7,8 +7,8 @@ on:
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
docs-scope:

View File

@@ -14,8 +14,8 @@ on:
- scripts/sandbox-common-setup.sh
concurrency:
group: sandbox-common-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
sandbox-common-smoke:

View File

@@ -6,8 +6,8 @@ on:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
no-tabs:

3
.gitignore vendored
View File

@@ -86,3 +86,6 @@ USER.md
!.agent/workflows/
/local/
package-lock.json
# Claude Code local settings
.claude/

View File

@@ -195,3 +195,39 @@
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Plugin Release Fast Path (no core `openclaw` publish)
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
- `eval "$(op signin --account my.1password.com)"`
- 1Password helpers:
- password used by `npm login`:
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
- OTP:
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
- compare local plugin `version` to `npm view <name> version`
- only run `npm publish --access public --otp="<otp>"` when versions differ
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.16`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
## Changelog Release Notes
- When cutting a mac release with beta GitHub prerelease:
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
- Keep top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first.
- `### Fixes` deduped and ranked with user-facing fixes first.
- Before tagging/publishing, run:
- `node --import tsx scripts/release-check.ts`
- `pnpm release:check`
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.

View File

@@ -2,48 +2,65 @@
Docs: https://docs.openclaw.ai
## 2026.2.15 (Unreleased)
## 2026.2.16 (Unreleased)
### Changes
- Build: add `pnpm build:runtime` for packagers/runtime builds to skip plugin-sdk declaration generation when types are not needed. (#17636) Thanks @joshp123.
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
### Fixes
- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
- Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (`allowInsecureAuth` / `dangerouslyDisableDeviceAuth`) when device identity is unavailable, preventing false `missing scope` failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
- LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
## 2026.2.14
@@ -58,6 +75,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent.
- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
@@ -97,6 +115,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
- Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient.
- Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi.
- Auto-reply/Prompts: include trusted inbound `message_id`, `chat_id`, `reply_to_id`, and optional `message_id_full` metadata fields so action tools (for example reactions) can target the triggering message without relying on user text. (#17662) Thanks @MaikiMolto.
- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
@@ -126,6 +145,7 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`.
- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
- Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c <collection>` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai.
- Memory/QMD: rebind managed collections when existing collection metadata drifts (including sessions name-only listings), preventing non-default agents from reusing another agent's `sessions` collection path. (#17194) Thanks @jonathanadams96.
- Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao.
- Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
@@ -159,6 +179,7 @@ Docs: https://docs.openclaw.ai
- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
- Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
- Security/Pairing: scope pairing allowlist writes/reads to channel accounts (for example `telegram:yy`), and propagate account-aware pairing approvals so multi-account channels do not share a single per-channel pairing allowFrom store. (#17631) Thanks @crazytan.
- Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.

View File

@@ -13,24 +13,33 @@ Welcome to the lobster tank! 🦞
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord + Slack subsystem
- **Shadow** - Discord subsystem, Discord admin
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster
- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
- **Ayaan Zaidi** - Telegram subsystem, iOS app
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
- **Mariano Belinky** - iOS app, Security
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
- **Christoph Nakazawa** - JS Infra
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
- GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!

View File

@@ -140,6 +140,74 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.14/OpenClaw-2026.2.14.zip" length="22914034" type="application/octet-stream" sparkle:edSignature="lR3nuq46/akMIN8RFDpMkTE0VOVoDVG53Xts589LryMGEtUvJxRQDtHBXfx7ZvToTq6CFKG+L5Kq/4rUspMoAQ=="/>
</item>
<item>
<title>2026.2.15</title>
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>11213</sparkle:version>
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
<h3>Changes</h3>
<ul>
<li>Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.</li>
<li>Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.</li>
<li>Plugins: expose <code>llm_input</code> and <code>llm_output</code> hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.</li>
<li>Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set <code>agents.defaults.subagents.maxSpawnDepth: 2</code> to allow sub-agents to spawn their own children. Includes <code>maxChildrenPerAgent</code> limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.</li>
<li>Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.</li>
<li>Cron/Gateway: add finished-run webhook delivery toggle (<code>notify</code>) and dedicated webhook auth token support (<code>cron.webhookToken</code>) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.</li>
<li>Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.</li>
<li>Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.</li>
<li>Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.</li>
<li>Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.</li>
<li>Gateway/Security: redact sensitive session/path details from <code>status</code> responses for non-admin clients; full details remain available to <code>operator.admin</code>. (#8590) Thanks @fr33d3m0n.</li>
<li>Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (<code>allowInsecureAuth</code> / <code>dangerouslyDisableDeviceAuth</code>) when device identity is unavailable, preventing false <code>missing scope</code> failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.</li>
<li>LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.</li>
<li>Skills/Security: restrict <code>download</code> installer <code>targetDir</code> to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.</li>
<li>Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.</li>
<li>Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.</li>
<li>Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving <code>passwordFile</code> path exemptions, preventing accidental redaction of non-secret config values like <code>maxTokens</code> and IRC password-file paths. (#16042) Thanks @akramcodez.</li>
<li>Dev tooling: harden git <code>pre-commit</code> hook against option injection from malicious filenames (for example <code>--force</code>), preventing accidental staging of ignored files. Thanks @mrthankyou.</li>
<li>Gateway/Agent: reject malformed <code>agent:</code>-prefixed session keys (for example, <code>agent:main</code>) in <code>agent</code> and <code>agent.identity.get</code> instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.</li>
<li>Gateway/Chat: harden <code>chat.send</code> inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.</li>
<li>Gateway/Send: return an actionable error when <code>send</code> targets internal-only <code>webchat</code>, guiding callers to use <code>chat.send</code> or a deliverable channel. (#15703) Thanks @rodrigouroz.</li>
<li>Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing <code>script-src 'self'</code>. Thanks @Adam55A-code.</li>
<li>Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.</li>
<li>Agents/Sandbox: clarify system prompt path guidance so sandbox <code>bash/exec</code> uses container paths (for example <code>/workspace</code>) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.</li>
<li>Agents/Context: apply configured model <code>contextWindow</code> overrides after provider discovery so <code>lookupContextTokens()</code> honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.</li>
<li>Agents/Context: derive <code>lookupContextTokens()</code> from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.</li>
<li>Agents/OpenAI: force <code>store=true</code> for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.</li>
<li>Memory/FTS: make <code>buildFtsQuery</code> Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.</li>
<li>Auto-reply/Compaction: resolve <code>memory/YYYY-MM-DD.md</code> placeholders with timezone-aware runtime dates and append a <code>Current time:</code> line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.</li>
<li>Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.</li>
<li>Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.</li>
<li>Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.</li>
<li>Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.</li>
<li>Subagents/Models: preserve <code>agents.defaults.model.fallbacks</code> when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.</li>
<li>Telegram: omit <code>message_thread_id</code> for DM sends/draft previews and keep forum-topic handling (<code>id=1</code> general omitted, non-general kept), preventing DM failures with <code>400 Bad Request: message thread not found</code>. (#10942) Thanks @garnetlyx.</li>
<li>Telegram: replace inbound <code><media:audio></code> placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.</li>
<li>Telegram: retry inbound media <code>getFile</code> calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.</li>
<li>Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.</li>
<li>Discord: preserve channel session continuity when runtime payloads omit <code>message.channelId</code> by falling back to event/raw <code>channel_id</code> values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as <code>sessionKey=unknown</code>. (#17622) Thanks @shakkernerd.</li>
<li>Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with <code>_2</code> suffixes. (#17365) Thanks @seewhyme.</li>
<li>Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.</li>
<li>Web UI/Agents: hide <code>BOOTSTRAP.md</code> in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.</li>
<li>Auto-reply/WhatsApp/TUI/Web: when a final assistant message is <code>NO_REPLY</code> and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show <code>NO_REPLY</code> placeholders. (#7010) Thanks @Morrowind-Xie.</li>
<li>Cron: infer <code>payload.kind="agentTurn"</code> for model-only <code>cron.update</code> payload patches, so partial agent-turn updates do not fail validation when <code>kind</code> is omitted. (#15664) Thanks @rodrigouroz.</li>
<li>TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.</li>
<li>TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.</li>
<li>TUI: suppress false <code>(no output)</code> placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.</li>
<li>TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.</li>
<li>CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
</item>
<item>
<title>2026.2.13</title>
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
@@ -241,101 +309,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.13/OpenClaw-2026.2.13.zip" length="22902077" type="application/octet-stream" sparkle:edSignature="RpkwlPtB2yN7UOYZWfthV5grhDUcbhcHMeicdRA864Vo/P0Hnq5aHKmSvcbWkjHut96TC57bX+AeUrL7txpLCg=="/>
</item>
<item>
<title>2026.2.12</title>
<pubDate>Fri, 13 Feb 2026 03:17:54 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>9500</sparkle:version>
<sparkle:shortVersionString>2026.2.12</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.12</h2>
<h3>Changes</h3>
<ul>
<li>CLI: add <code>openclaw logs --local-time</code> to display log timestamps in local timezone. (#13818) Thanks @xialonglee.</li>
<li>Telegram: render blockquotes as native <code><blockquote></code> tags instead of stripping them. (#14608)</li>
<li>Config: avoid redacting <code>maxTokens</code>-like fields during config snapshot redaction, preventing round-trip validation failures in <code>/config</code>. (#14006) Thanks @constansino.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>Hooks: <code>POST /hooks/agent</code> now rejects payload <code>sessionKey</code> overrides by default. To keep fixed hook context, set <code>hooks.defaultSessionKey</code> (recommended with <code>hooks.allowedSessionKeyPrefixes: ["hook:"]</code>). If you need legacy behavior, explicitly set <code>hooks.allowRequestSessionKey: true</code>. Thanks @alpernae for reporting.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Gateway/OpenResponses: harden URL-based <code>input_file</code>/<code>input_image</code> handling with explicit SSRF deny policy, hostname allowlists (<code>files.urlAllowlist</code> / <code>images.urlAllowlist</code>), per-request URL input caps (<code>maxUrlParts</code>), blocked-fetch audit logging, and regression coverage/docs updates.</li>
<li>Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.</li>
<li>Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.</li>
<li>Security/Audit: add hook session-routing hardening checks (<code>hooks.defaultSessionKey</code>, <code>hooks.allowRequestSessionKey</code>, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.</li>
<li>Security/Sandbox: confine mirrored skill sync destinations to the sandbox <code>skills/</code> root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.</li>
<li>Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip <code>toolResult.details</code> from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.</li>
<li>Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (<code>429</code> + <code>Retry-After</code>). Thanks @akhmittra.</li>
<li>Security/Browser: require auth for loopback browser control HTTP routes, auto-generate <code>gateway.auth.token</code> when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.</li>
<li>Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.</li>
<li>Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.</li>
<li>Logging/CLI: use local timezone timestamps for console prefixing, and include <code>±HH:MM</code> offsets when using <code>openclaw logs --local-time</code> to avoid ambiguity. (#14771) Thanks @0xRaini.</li>
<li>Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.</li>
<li>Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.</li>
<li>Gateway: prevent <code>undefined</code>/missing token in auth config. (#13809) Thanks @asklee-klawd.</li>
<li>Gateway: handle async <code>EPIPE</code> on stdout/stderr during shutdown. (#13414) Thanks @keshav55.</li>
<li>Gateway/Control UI: resolve missing dashboard assets when <code>openclaw</code> is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.</li>
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
<li>Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after <code>requests-in-flight</code> skips. (#14901) Thanks @joeykrug.</li>
<li>Cron: honor stored session model overrides for isolated-agent runs while preserving <code>hooks.gmail.model</code> precedence for Gmail hook sessions. (#14983) Thanks @shtse8.</li>
<li>Logging/Browser: fall back to <code>os.tmpdir()/openclaw</code> for default log, browser trace, and browser download temp paths when <code>/tmp/openclaw</code> is unavailable.</li>
<li>WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.</li>
<li>WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.</li>
<li>WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.</li>
<li>Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.</li>
<li>Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.</li>
<li>BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.</li>
<li>Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.</li>
<li>Slack: detect control commands when channel messages start with bot mention prefixes (for example, <code>@Bot /new</code>). (#14142) Thanks @beefiker.</li>
<li>Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.</li>
<li>Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.</li>
<li>Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.</li>
<li>Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.</li>
<li>Signal: render mention placeholders as <code>@uuid</code>/<code>@phone</code> so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.</li>
<li>Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.</li>
<li>Onboarding/Providers: add Z.AI endpoint-specific auth choices (<code>zai-coding-global</code>, <code>zai-coding-cn</code>, <code>zai-global</code>, <code>zai-cn</code>) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.</li>
<li>Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include <code>minimax-m2.5</code> in modern model filtering. (#14865) Thanks @adao-max.</li>
<li>Ollama: use configured <code>models.providers.ollama.baseUrl</code> for model discovery and normalize <code>/v1</code> endpoints to the native Ollama API root. (#14131) Thanks @shtse8.</li>
<li>Voice Call: pass Twilio stream auth token via <code><Parameter></code> instead of query string. (#14029) Thanks @mcwigglesmcgee.</li>
<li>Feishu: pass <code>Buffer</code> directly to the Feishu SDK upload APIs instead of <code>Readable.from(...)</code> to avoid form-data upload failures. (#10345) Thanks @youngerstyle.</li>
<li>Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.</li>
<li>Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.</li>
<li>Feishu DocX: preserve top-level converted block order using <code>firstLevelBlockIds</code> when writing/appending documents. (#13994) Thanks @Cynosure159.</li>
<li>Feishu plugin packaging: remove <code>workspace:*</code> <code>openclaw</code> dependency from <code>extensions/feishu</code> and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.</li>
<li>CLI/Wizard: exit with code 1 when <code>configure</code>, <code>agents add</code>, or interactive <code>onboard</code> wizards are canceled, so <code>set -e</code> automation stops correctly. (#14156) Thanks @0xRaini.</li>
<li>Media: strip <code>MEDIA:</code> lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.</li>
<li>Config/Cron: exclude <code>maxTokens</code> from config redaction and honor <code>deleteAfterRun</code> on skipped cron jobs. (#13342) Thanks @niceysam.</li>
<li>Config: ignore <code>meta</code> field changes in config file watcher. (#13460) Thanks @brandonwise.</li>
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
<li>Daemon: suppress <code>EPIPE</code> error when restarting LaunchAgent. (#14343) Thanks @0xRaini.</li>
<li>Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.</li>
<li>Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.</li>
<li>Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.</li>
<li>Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.</li>
<li>Agents: keep followup-runner session <code>totalTokens</code> aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.</li>
<li>Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.</li>
<li>Hooks/Tools: dispatch <code>before_tool_call</code> and <code>after_tool_call</code> hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.</li>
<li>Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.</li>
<li>Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.</li>
<li>Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.12/OpenClaw-2026.2.12.zip" length="22877692" type="application/octet-stream" sparkle:edSignature="TGylTM4/7Lab+qp1nuPeOAmEVV1WkafXUPub8ws0z/0mYfbVygRuiev+u3zdPjQWhLnGYTgRgKVyW+kB2+Q2BQ=="/>
</item>
</channel>
</rss>

View File

@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602150
versionName = "2026.2.15"
versionCode = 202602160
versionName = "2026.2.16"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.15</string>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>20260215</string>
<string>20260216</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.15</string>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>20260215</string>
<string>20260216</string>
</dict>
</plist>

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.2.15"
CFBundleVersion: "20260215"
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.15"
CFBundleVersion: "20260215"
CFBundleShortVersionString: "2026.2.16"
CFBundleVersion: "20260216"

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.15</string>
<string>2026.2.16</string>
<key>CFBundleVersion</key>
<string>202602150</string>
<string>202602160</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -0,0 +1,667 @@
(() => {
if (document.getElementById("docs-chat-root")) return;
// Determine if we're on the docs site or embedded elsewhere
const hostname = window.location.hostname;
const isDocsSite = hostname === "localhost" || hostname === "127.0.0.1" ||
hostname.includes("docs.openclaw") || hostname.endsWith(".mintlify.app");
const assetsBase = isDocsSite ? "" : "https://docs.openclaw.ai";
const apiBase = "https://claw-api.openknot.ai/api";
// Load marked for markdown rendering (via CDN)
let markedReady = false;
const loadMarkdownLib = () => {
if (window.marked) {
markedReady = true;
return;
}
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/marked@15.0.6/marked.min.js";
script.onload = () => {
if (window.marked) {
markedReady = true;
}
};
script.onerror = () => console.warn("Failed to load marked library");
document.head.appendChild(script);
};
loadMarkdownLib();
// Markdown renderer with fallback before module loads
const renderMarkdown = (text) => {
if (markedReady && window.marked) {
// Configure marked for security: disable HTML pass-through
const html = window.marked.parse(text, { async: false, gfm: true, breaks: true });
// Open links in new tab by rewriting <a> tags
return html.replace(/<a href="/g, '<a target="_blank" rel="noopener" href="');
}
// Fallback: escape HTML and preserve newlines
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
};
const style = document.createElement("style");
style.textContent = `
#docs-chat-root { position: fixed; right: 20px; bottom: 20px; z-index: 9999; font-family: var(--font-body, system-ui, -apple-system, sans-serif); }
#docs-chat-root.docs-chat-expanded { right: 0; bottom: 0; top: 0; }
/* Thin scrollbar styling */
#docs-chat-root ::-webkit-scrollbar { width: 6px; height: 6px; }
#docs-chat-root ::-webkit-scrollbar-track { background: transparent; }
#docs-chat-root ::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); border-radius: 3px; }
#docs-chat-root ::-webkit-scrollbar-thumb:hover { background: var(--docs-chat-muted); }
#docs-chat-root * { scrollbar-width: thin; scrollbar-color: var(--docs-chat-panel-border) transparent; }
:root {
--docs-chat-accent: var(--accent, #ff7d60);
--docs-chat-text: #1a1a1a;
--docs-chat-muted: #555;
--docs-chat-panel: rgba(255, 255, 255, 0.92);
--docs-chat-panel-border: rgba(0, 0, 0, 0.1);
--docs-chat-surface: rgba(250, 250, 250, 0.95);
--docs-chat-shadow: 0 18px 50px rgba(0,0,0,0.15);
--docs-chat-code-bg: rgba(0, 0, 0, 0.05);
--docs-chat-assistant-bg: #f5f5f5;
}
html[data-theme="dark"] {
--docs-chat-text: #e8e8e8;
--docs-chat-muted: #aaa;
--docs-chat-panel: rgba(28, 28, 30, 0.95);
--docs-chat-panel-border: rgba(255, 255, 255, 0.12);
--docs-chat-surface: rgba(38, 38, 40, 0.95);
--docs-chat-shadow: 0 18px 50px rgba(0,0,0,0.5);
--docs-chat-code-bg: rgba(255, 255, 255, 0.08);
--docs-chat-assistant-bg: #2a2a2c;
}
#docs-chat-button {
display: inline-flex;
align-items: center;
gap: 10px;
background: linear-gradient(140deg, rgba(255,90,54,0.25), rgba(255,90,54,0.06));
color: var(--docs-chat-text);
border: 1px solid rgba(255,90,54,0.4);
border-radius: 999px;
padding: 10px 14px;
cursor: pointer;
box-shadow: 0 8px 30px rgba(255,90,54, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
font-family: var(--font-pixel, var(--font-body, system-ui, sans-serif));
}
#docs-chat-button span { font-weight: 600; letter-spacing: 0.04em; font-size: 14px; }
.docs-chat-logo { width: 20px; height: 20px; }
#docs-chat-panel {
width: min(440px, calc(100vw - 40px));
height: min(696px, calc(100vh - 80px));
background: var(--docs-chat-panel);
color: var(--docs-chat-text);
border-radius: 16px;
border: 1px solid var(--docs-chat-panel-border);
box-shadow: var(--docs-chat-shadow);
display: none;
flex-direction: column;
overflow: hidden;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
#docs-chat-root.docs-chat-expanded #docs-chat-panel {
width: min(512px, 100vw);
height: 100vh;
height: 100dvh;
border-radius: 18px 0 0 18px;
padding-top: env(safe-area-inset-top, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
}
@media (max-width: 520px) {
#docs-chat-root.docs-chat-expanded #docs-chat-panel {
width: 100vw;
border-radius: 0;
}
#docs-chat-root.docs-chat-expanded { right: 0; left: 0; bottom: 0; top: 0; }
}
#docs-chat-header {
padding: 12px 14px;
font-weight: 600;
font-family: var(--font-pixel, var(--font-body, system-ui, sans-serif));
letter-spacing: 0.03em;
border-bottom: 1px solid var(--docs-chat-panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
#docs-chat-header-title { display: inline-flex; align-items: center; gap: 8px; }
#docs-chat-header-title span { color: var(--docs-chat-text); font-size: 15px; }
#docs-chat-header-actions { display: inline-flex; align-items: center; gap: 6px; }
.docs-chat-icon-button {
border: 1px solid var(--docs-chat-panel-border);
background: transparent;
color: inherit;
border-radius: 8px;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
#docs-chat-messages { flex: 1; padding: 12px 14px; overflow: auto; background: transparent; }
#docs-chat-input {
display: flex;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid var(--docs-chat-panel-border);
background: var(--docs-chat-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#docs-chat-input textarea {
flex: 1;
resize: none;
border: 1px solid var(--docs-chat-panel-border);
border-radius: 10px;
padding: 9px 10px;
font-size: 14px;
line-height: 1.5;
font-family: inherit;
color: var(--docs-chat-text);
background: var(--docs-chat-surface);
min-height: 42px;
max-height: 120px;
overflow-y: auto;
}
#docs-chat-input textarea::placeholder { color: var(--docs-chat-muted); }
#docs-chat-send {
background: var(--docs-chat-accent);
color: #fff;
border: none;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
font-family: inherit;
font-size: 14px;
transition: opacity 0.15s ease;
}
#docs-chat-send:hover { opacity: 0.9; }
#docs-chat-send:active { opacity: 0.8; }
.docs-chat-bubble {
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
max-width: 92%;
}
.docs-chat-user {
background: rgba(255, 125, 96, 0.15);
color: var(--docs-chat-text);
border: 1px solid rgba(255, 125, 96, 0.3);
align-self: flex-end;
white-space: pre-wrap;
margin-left: auto;
}
html[data-theme="dark"] .docs-chat-user {
background: rgba(255, 125, 96, 0.18);
border-color: rgba(255, 125, 96, 0.35);
}
.docs-chat-assistant {
background: var(--docs-chat-assistant-bg);
color: var(--docs-chat-text);
border: 1px solid var(--docs-chat-panel-border);
}
/* Markdown content styling for chat bubbles */
.docs-chat-assistant p { margin: 0 0 10px 0; }
.docs-chat-assistant p:last-child { margin-bottom: 0; }
.docs-chat-assistant code {
background: var(--docs-chat-code-bg);
padding: 2px 6px;
border-radius: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
}
.docs-chat-assistant pre {
background: var(--docs-chat-code-bg);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 6px 0;
font-size: 0.9em;
max-width: 100%;
white-space: pre;
word-wrap: normal;
}
.docs-chat-assistant pre::-webkit-scrollbar-thumb { background: transparent; }
.docs-chat-assistant pre:hover::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); }
@media (hover: none) {
.docs-chat-assistant pre { -webkit-overflow-scrolling: touch; }
.docs-chat-assistant pre::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); }
}
.docs-chat-assistant pre code {
background: transparent;
padding: 0;
font-size: inherit;
white-space: pre;
word-wrap: normal;
display: block;
}
/* Compact single-line code blocks */
.docs-chat-assistant pre.compact {
margin: 4px 0;
padding: 6px 10px;
}
/* Longer code blocks with copy button need extra top padding */
.docs-chat-assistant pre:not(.compact) {
padding-top: 28px;
}
.docs-chat-assistant a {
color: var(--docs-chat-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.docs-chat-assistant a:hover { opacity: 0.8; }
.docs-chat-assistant ul, .docs-chat-assistant ol {
margin: 8px 0;
padding-left: 18px;
list-style: none;
}
.docs-chat-assistant li {
margin: 4px 0;
position: relative;
padding-left: 14px;
}
.docs-chat-assistant li::before {
content: "•";
position: absolute;
left: 0;
color: var(--docs-chat-muted);
}
.docs-chat-assistant strong { font-weight: 600; }
.docs-chat-assistant em { font-style: italic; }
.docs-chat-assistant h1, .docs-chat-assistant h2, .docs-chat-assistant h3 {
font-weight: 600;
margin: 12px 0 6px 0;
line-height: 1.3;
}
.docs-chat-assistant h1 { font-size: 1.2em; }
.docs-chat-assistant h2 { font-size: 1.1em; }
.docs-chat-assistant h3 { font-size: 1.05em; }
.docs-chat-assistant blockquote {
border-left: 3px solid var(--docs-chat-accent);
margin: 10px 0;
padding: 4px 12px;
color: var(--docs-chat-muted);
background: var(--docs-chat-code-bg);
border-radius: 0 6px 6px 0;
}
.docs-chat-assistant hr {
border: none;
height: 1px;
background: var(--docs-chat-panel-border);
margin: 12px 0;
}
/* Copy buttons */
.docs-chat-assistant { position: relative; padding-top: 28px; }
.docs-chat-copy-response {
position: absolute;
top: 8px;
right: 8px;
background: var(--docs-chat-surface);
border: 1px solid var(--docs-chat-panel-border);
border-radius: 5px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
color: var(--docs-chat-muted);
transition: color 0.15s ease, background 0.15s ease;
}
.docs-chat-copy-response:hover {
color: var(--docs-chat-text);
background: var(--docs-chat-code-bg);
}
.docs-chat-assistant pre {
position: relative;
}
.docs-chat-copy-code {
position: absolute;
top: 8px;
right: 8px;
background: var(--docs-chat-surface);
border: 1px solid var(--docs-chat-panel-border);
border-radius: 4px;
padding: 3px 7px;
font-size: 10px;
cursor: pointer;
color: var(--docs-chat-muted);
transition: color 0.15s ease, background 0.15s ease;
z-index: 1;
}
.docs-chat-copy-code:hover {
color: var(--docs-chat-text);
background: var(--docs-chat-code-bg);
}
/* Resize handle - left edge of expanded panel */
#docs-chat-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 10;
display: none;
}
#docs-chat-root.docs-chat-expanded #docs-chat-resize-handle { display: block; }
#docs-chat-resize-handle::after {
content: "";
position: absolute;
left: 1px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 40px;
border-radius: 2px;
background: var(--docs-chat-panel-border);
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease;
}
#docs-chat-resize-handle:hover::after,
#docs-chat-resize-handle.docs-chat-dragging::after {
opacity: 1;
background: var(--docs-chat-accent);
}
@media (max-width: 520px) {
#docs-chat-resize-handle { display: none !important; }
}
`;
document.head.appendChild(style);
const root = document.createElement("div");
root.id = "docs-chat-root";
const button = document.createElement("button");
button.id = "docs-chat-button";
button.type = "button";
button.innerHTML =
`<img class="docs-chat-logo" src="${assetsBase}/assets/pixel-lobster.svg" alt="OpenClaw">` +
`<span>Ask Molty</span>`;
const panel = document.createElement("div");
panel.id = "docs-chat-panel";
panel.style.display = "none";
// Resize handle for expandable sidebar width (desktop only)
const resizeHandle = document.createElement("div");
resizeHandle.id = "docs-chat-resize-handle";
const header = document.createElement("div");
header.id = "docs-chat-header";
header.innerHTML =
`<div id="docs-chat-header-title">` +
`<img class="docs-chat-logo" src="${assetsBase}/assets/pixel-lobster.svg" alt="OpenClaw">` +
`<span>OpenClaw Docs</span>` +
`</div>` +
`<div id="docs-chat-header-actions"></div>`;
const headerActions = header.querySelector("#docs-chat-header-actions");
const expand = document.createElement("button");
expand.type = "button";
expand.className = "docs-chat-icon-button";
expand.setAttribute("aria-label", "Expand");
expand.textContent = "⤢";
const clear = document.createElement("button");
clear.type = "button";
clear.className = "docs-chat-icon-button";
clear.setAttribute("aria-label", "Clear chat");
clear.textContent = "⌫";
const close = document.createElement("button");
close.type = "button";
close.className = "docs-chat-icon-button";
close.setAttribute("aria-label", "Close");
close.textContent = "×";
headerActions.appendChild(expand);
headerActions.appendChild(clear);
headerActions.appendChild(close);
const messages = document.createElement("div");
messages.id = "docs-chat-messages";
const inputWrap = document.createElement("div");
inputWrap.id = "docs-chat-input";
const textarea = document.createElement("textarea");
textarea.rows = 1;
textarea.placeholder = "Ask about OpenClaw Docs...";
// Auto-expand textarea as user types (up to max-height set in CSS)
const autoExpand = () => {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 224) + "px";
};
textarea.addEventListener("input", autoExpand);
const send = document.createElement("button");
send.id = "docs-chat-send";
send.type = "button";
send.textContent = "Send";
inputWrap.appendChild(textarea);
inputWrap.appendChild(send);
panel.appendChild(resizeHandle);
panel.appendChild(header);
panel.appendChild(messages);
panel.appendChild(inputWrap);
root.appendChild(button);
root.appendChild(panel);
document.body.appendChild(root);
// Add copy buttons to assistant bubble
const addCopyButtons = (bubble, rawText) => {
// Add copy response button
const copyResponse = document.createElement("button");
copyResponse.className = "docs-chat-copy-response";
copyResponse.textContent = "Copy";
copyResponse.type = "button";
copyResponse.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(rawText);
copyResponse.textContent = "Copied!";
setTimeout(() => (copyResponse.textContent = "Copy"), 1500);
} catch (e) {
copyResponse.textContent = "Failed";
}
});
bubble.appendChild(copyResponse);
// Add copy buttons to code blocks (skip short/single-line blocks)
bubble.querySelectorAll("pre").forEach((pre) => {
const code = pre.querySelector("code") || pre;
const text = code.textContent || "";
const lineCount = text.split("\n").length;
const isShort = lineCount <= 2 && text.length < 100;
if (isShort) {
pre.classList.add("compact");
return; // Skip copy button for compact blocks
}
const copyCode = document.createElement("button");
copyCode.className = "docs-chat-copy-code";
copyCode.textContent = "Copy";
copyCode.type = "button";
copyCode.addEventListener("click", async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
copyCode.textContent = "Copied!";
setTimeout(() => (copyCode.textContent = "Copy"), 1500);
} catch (err) {
copyCode.textContent = "Failed";
}
});
pre.appendChild(copyCode);
});
};
const addBubble = (text, role, isMarkdown = false) => {
const bubble = document.createElement("div");
bubble.className =
"docs-chat-bubble " +
(role === "user" ? "docs-chat-user" : "docs-chat-assistant");
if (isMarkdown && role === "assistant") {
bubble.innerHTML = renderMarkdown(text);
} else {
bubble.textContent = text;
}
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
return bubble;
};
let isExpanded = false;
let customWidth = null; // User-set width via drag
const MIN_WIDTH = 320;
const MAX_WIDTH = 800;
// Drag-to-resize logic
let isDragging = false;
let startX, startWidth;
resizeHandle.addEventListener("mousedown", (e) => {
if (!isExpanded) return;
isDragging = true;
startX = e.clientX;
startWidth = panel.offsetWidth;
resizeHandle.classList.add("docs-chat-dragging");
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
// Panel is on right, so dragging left increases width
const delta = startX - e.clientX;
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
customWidth = newWidth;
panel.style.width = newWidth + "px";
});
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
resizeHandle.classList.remove("docs-chat-dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
});
const setOpen = (isOpen) => {
panel.style.display = isOpen ? "flex" : "none";
button.style.display = isOpen ? "none" : "inline-flex";
root.classList.toggle("docs-chat-expanded", isOpen && isExpanded);
if (!isOpen) {
panel.style.width = ""; // Reset to CSS default when closed
} else if (isExpanded && customWidth) {
panel.style.width = customWidth + "px";
}
if (isOpen) textarea.focus();
};
const setExpanded = (next) => {
isExpanded = next;
expand.textContent = isExpanded ? "⤡" : "⤢";
expand.setAttribute("aria-label", isExpanded ? "Collapse" : "Expand");
if (panel.style.display !== "none") {
root.classList.toggle("docs-chat-expanded", isExpanded);
if (isExpanded && customWidth) {
panel.style.width = customWidth + "px";
} else if (!isExpanded) {
panel.style.width = ""; // Reset to CSS default
}
}
};
button.addEventListener("click", () => setOpen(true));
expand.addEventListener("click", () => setExpanded(!isExpanded));
clear.addEventListener("click", () => {
messages.innerHTML = "";
});
close.addEventListener("click", () => {
setOpen(false);
root.classList.remove("docs-chat-expanded");
});
const sendMessage = async () => {
const text = textarea.value.trim();
if (!text) return;
textarea.value = "";
textarea.style.height = "auto"; // Reset height after sending
addBubble(text, "user");
const assistantBubble = addBubble("...", "assistant");
assistantBubble.innerHTML = "";
let fullText = "";
try {
const response = await fetch(`${apiBase}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
// Handle rate limiting
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") || "60";
fullText = `You're asking questions too quickly. Please wait ${retryAfter} seconds before trying again.`;
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
return;
}
// Handle other errors
if (!response.ok) {
try {
const errorData = await response.json();
fullText = errorData.error || "Something went wrong. Please try again.";
} catch {
fullText = "Something went wrong. Please try again.";
}
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
return;
}
if (!response.body) {
fullText = await response.text();
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
fullText += decoder.decode(value, { stream: true });
// Re-render markdown on each chunk for live preview
assistantBubble.innerHTML = renderMarkdown(fullText);
messages.scrollTop = messages.scrollHeight;
}
// Flush any remaining buffered bytes (partial UTF-8 sequences)
fullText += decoder.decode();
assistantBubble.innerHTML = renderMarkdown(fullText);
// Add copy buttons after streaming completes
addCopyButtons(assistantBubble, fullText);
} catch (err) {
fullText = "Failed to reach docs chat API.";
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
}
};
send.addEventListener("click", sendMessage);
textarea.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
})();

View File

@@ -87,6 +87,77 @@ Token resolution is account-aware. Config token values win over env fallback. `D
- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`).
- Native slash commands run in isolated command sessions (`agent:<agentId>:discord:slash:<userId>`), while still carrying `CommandTargetSessionKey` to the routed conversation session.
## Interactive components
OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings.
Supported blocks:
- `text`, `section`, `separator`, `actions`, `media-gallery`, `file`
- Action rows allow up to 5 buttons or a single select menu
- Select types: `string`, `user`, `role`, `mentionable`, `channel`
File attachments:
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files
- Use `filename` to override the upload name when it should match the attachment reference
Modal forms:
- Add `components.modal` with up to 5 fields
- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select`
- OpenClaw adds a trigger button automatically
Example:
```json5
{
channel: "discord",
action: "send",
to: "channel:123456789012345678",
message: "Optional fallback text",
components: {
text: "Choose a path",
blocks: [
{
type: "actions",
buttons: [
{ label: "Approve", style: "success" },
{ label: "Decline", style: "danger" },
],
},
{
type: "actions",
select: {
type: "string",
placeholder: "Pick an option",
options: [
{ label: "Option A", value: "a" },
{ label: "Option B", value: "b" },
],
},
},
],
modal: {
title: "Details",
triggerLabel: "Open form",
fields: [
{ type: "text", label: "Requester" },
{
type: "select",
label: "Priority",
options: [
{ label: "Low", value: "low" },
{ label: "High", value: "high" },
],
},
],
},
},
}
```
## Access control and routing
<Tabs>

View File

@@ -176,12 +176,24 @@ Behavior:
## Sandbox Session Visibility
Sandboxed sessions can use session tools, but by default they only see sessions they spawned via `sessions_spawn`.
Session tools can be scoped to reduce cross-session access.
Default behavior:
- `tools.sessions.visibility` defaults to `tree` (current session + spawned subagent sessions).
- For sandboxed sessions, `agents.defaults.sandbox.sessionToolsVisibility` can hard-clamp visibility.
Config:
```json5
{
tools: {
sessions: {
// "self" | "tree" | "agent" | "all"
// default: "tree"
visibility: "tree",
},
},
agents: {
defaults: {
sandbox: {
@@ -192,3 +204,11 @@ Config:
},
}
```
Notes:
- `self`: only the current session key.
- `tree`: current session + sessions spawned by the current session.
- `agent`: any session belonging to the current agent id.
- `all`: any session (cross-agent access still requires `tools.agentToAgent`).
- When a session is sandboxed and `sessionToolsVisibility="spawned"`, OpenClaw clamps visibility to `tree` even if you set `tools.sessions.visibility="all"`.

View File

@@ -1508,6 +1508,31 @@ Provider auth follows standard order: auth profiles → env vars → `models.pro
}
```
### `tools.sessions`
Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`).
Default: `tree` (current session + sessions spawned by it, such as subagents).
```json5
{
tools: {
sessions: {
// "self" | "tree" | "agent" | "all"
visibility: "tree",
},
},
}
```
Notes:
- `self`: only the current session key.
- `tree`: current session + sessions spawned by the current session (subagents).
- `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under the same agent id).
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
### `tools.subagents`
```json5

View File

@@ -710,7 +710,11 @@ Common use cases:
scope: "agent",
workspaceAccess: "none",
},
// Session tools can reveal sensitive data from transcripts. By default OpenClaw limits these tools
// to the current session + spawned subagent sessions, but you can clamp further if needed.
// See `tools.sessions.visibility` in the configuration reference.
tools: {
sessions: { visibility: "tree" }, // self | tree | agent | all
allow: [
"sessions_list",
"sessions_history",

View File

@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.15 \
APP_VERSION=2026.2.16 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.15.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.16.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.16.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.15.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.15 \
APP_VERSION=2026.2.16 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.15.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.16.dSYM.zip
```
## Appcast entry
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.15.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.16.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.2.15.zip` (and `OpenClaw-2026.2.15.dSYM.zip`) to the GitHub release for tag `v2026.2.15`.
- Upload `OpenClaw-2026.2.16.zip` (and `OpenClaw-2026.2.16.dSYM.zip`) to the GitHub release for tag `v2026.2.16`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@@ -442,12 +442,14 @@ Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
- `sessions_send` runs a replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).
- After the pingpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
### `agents_list`

View File

@@ -324,6 +324,7 @@ Legacy `agent.*` configs are migrated by `openclaw doctor`; prefer `agents.defau
```json
{
"tools": {
"sessions": { "visibility": "tree" },
"allow": ["sessions_list", "sessions_send", "sessions_history", "session_status"],
"deny": ["exec", "write", "edit", "apply_patch", "read", "browser"]
}

View File

@@ -99,7 +99,8 @@ Text + native (when enabled):
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/model <name>` (alias: `.model`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/models [provider]` (alias: `.models`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -158,6 +158,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
},
agentPrompt: {
messageToolHints: () => [
"- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
"- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.",
],
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
targetResolver: {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@@ -0,0 +1,101 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
function createRuntime() {
const probeLineBot = vi.fn(async () => ({ ok: false }));
const monitorLineProvider = vi.fn(async () => ({
account: { accountId: "default" },
handleWebhook: async () => {},
stop: () => {},
}));
const runtime = {
channel: {
line: {
probeLineBot,
monitorLineProvider,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
return { runtime, probeLineBot, monitorLineProvider };
}
function createStartAccountCtx(params: { token: string; secret: string; runtime: unknown }) {
return {
account: {
accountId: "default",
channelAccessToken: params.token,
channelSecret: params.secret,
config: {},
},
cfg: {} as OpenClawConfig,
runtime: params.runtime,
abortSignal: undefined,
log: { info: vi.fn(), debug: vi.fn() },
};
}
describe("linePlugin gateway.startAccount", () => {
it("fails startup when channel secret is missing", async () => {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
await expect(
linePlugin.gateway.startAccount(
createStartAccountCtx({
token: "token",
secret: " ",
runtime: {},
}) as never,
),
).rejects.toThrow(
'LINE webhook mode requires a non-empty channel secret for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("fails startup when channel access token is missing", async () => {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
await expect(
linePlugin.gateway.startAccount(
createStartAccountCtx({
token: " ",
secret: "secret",
runtime: {},
}) as never,
),
).rejects.toThrow(
'LINE webhook mode requires a non-empty channel access token for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("starts provider when token and secret are present", async () => {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
await linePlugin.gateway.startAccount(
createStartAccountCtx({
token: "token",
secret: "secret",
runtime: {},
}) as never,
);
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
});
});

View File

@@ -119,12 +119,13 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
},
};
},
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
isConfigured: (account) =>
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.channelAccessToken?.trim()),
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
tokenSource: account.tokenSource ?? undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
@@ -603,7 +604,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
probeAccount: async ({ account, timeoutMs }) =>
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.channelAccessToken?.trim());
const configured = Boolean(
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
);
return {
accountId: account.accountId,
name: account.name,
@@ -626,6 +629,16 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
if (!token) {
throw new Error(
`LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`,
);
}
if (!secret) {
throw new Error(
`LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`,
);
}
let lineBotLabel = "";
try {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Mattermost channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* LanceDB performance benchmark
*/
import * as lancedb from "@lancedb/lancedb";
import OpenAI from "openai";
const LANCEDB_PATH = "/home/tsukhani/.openclaw/memory/lancedb";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
async function embed(text) {
const start = Date.now();
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
const embedTime = Date.now() - start;
return { vector: response.data[0].embedding, embedTime };
}
async function main() {
console.log("📊 LanceDB Performance Benchmark");
console.log("================================\n");
// Connect
const connectStart = Date.now();
const db = await lancedb.connect(LANCEDB_PATH);
const table = await db.openTable("memories");
const connectTime = Date.now() - connectStart;
console.log(`Connection time: ${connectTime}ms`);
const count = await table.countRows();
console.log(`Total memories: ${count}\n`);
// Test queries
const queries = [
"Tarun's preferences",
"What is the OpenRouter API key location?",
"meeting schedule",
"Abundent Academy training",
"slate blue",
];
console.log("Search benchmarks (5 runs each, limit=5):\n");
for (const query of queries) {
const times = [];
let embedTime = 0;
for (let i = 0; i < 5; i++) {
const { vector, embedTime: et } = await embed(query);
embedTime = et; // Last one
const searchStart = Date.now();
const _results = await table.vectorSearch(vector).limit(5).toArray();
const searchTime = Date.now() - searchStart;
times.push(searchTime);
}
const avg = Math.round(times.reduce((a, b) => a + b, 0) / times.length);
const min = Math.min(...times);
const max = Math.max(...times);
console.log(`"${query}"`);
console.log(` Embedding: ${embedTime}ms`);
console.log(` Search: avg=${avg}ms, min=${min}ms, max=${max}ms`);
console.log("");
}
// Raw vector search (no embedding)
console.log("\nRaw vector search (pre-computed embedding):");
const { vector } = await embed("test query");
const rawTimes = [];
for (let i = 0; i < 10; i++) {
const start = Date.now();
await table.vectorSearch(vector).limit(5).toArray();
rawTimes.push(Date.now() - start);
}
const avgRaw = Math.round(rawTimes.reduce((a, b) => a + b, 0) / rawTimes.length);
console.log(` avg=${avgRaw}ms, min=${Math.min(...rawTimes)}ms, max=${Math.max(...rawTimes)}ms`);
}
main().catch(console.error);

View File

@@ -2,6 +2,20 @@ import fs from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type AutoCaptureConfig = {
enabled: boolean;
/** LLM provider for memory extraction: "openrouter" (default) or "openai" */
provider?: "openrouter" | "openai";
/** LLM model for memory extraction (default: google/gemini-2.0-flash-001) */
model?: string;
/** API key for the LLM provider (supports ${ENV_VAR} syntax) */
apiKey?: string;
/** Base URL for the LLM provider (default: https://openrouter.ai/api/v1) */
baseUrl?: string;
/** Maximum messages to send for extraction (default: 10) */
maxMessages?: number;
};
export type MemoryConfig = {
embedding: {
provider: "openai";
@@ -9,12 +23,27 @@ export type MemoryConfig = {
apiKey: string;
};
dbPath?: string;
autoCapture?: boolean;
/** @deprecated Use autoCapture object instead. Boolean true enables with defaults. */
autoCapture?: boolean | AutoCaptureConfig;
autoRecall?: boolean;
captureMaxChars?: number;
coreMemory?: {
enabled?: boolean;
/** Maximum number of core memories to load */
maxEntries?: number;
/** Minimum importance threshold for core memories */
minImportance?: number;
};
};
export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
export const MEMORY_CATEGORIES = [
"preference",
"fact",
"decision",
"entity",
"other",
"core",
] as const;
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
const DEFAULT_MODEL = "text-embedding-3-small";
@@ -93,7 +122,7 @@ export const memoryConfigSchema = {
const cfg = value as Record<string, unknown>;
assertAllowedKeys(
cfg,
["embedding", "dbPath", "autoCapture", "autoRecall", "captureMaxChars"],
["embedding", "dbPath", "autoCapture", "autoRecall", "captureMaxChars", "coreMemory"],
"memory config",
);
@@ -114,6 +143,43 @@ export const memoryConfigSchema = {
throw new Error("captureMaxChars must be between 100 and 10000");
}
// Parse autoCapture (supports boolean for backward compat, or object for LLM config)
let autoCapture: MemoryConfig["autoCapture"];
if (cfg.autoCapture === false || cfg.autoCapture === undefined) {
autoCapture = false;
} else if (cfg.autoCapture === true) {
// Legacy boolean true — enable with defaults
autoCapture = { enabled: true };
} else if (typeof cfg.autoCapture === "object" && !Array.isArray(cfg.autoCapture)) {
const ac = cfg.autoCapture as Record<string, unknown>;
assertAllowedKeys(
ac,
["enabled", "provider", "model", "apiKey", "baseUrl", "maxMessages"],
"autoCapture config",
);
autoCapture = {
enabled: ac.enabled !== false,
provider:
ac.provider === "openai" || ac.provider === "openrouter" ? ac.provider : "openrouter",
model: typeof ac.model === "string" ? ac.model : undefined,
apiKey: typeof ac.apiKey === "string" ? resolveEnvVars(ac.apiKey) : undefined,
baseUrl: typeof ac.baseUrl === "string" ? ac.baseUrl : undefined,
maxMessages: typeof ac.maxMessages === "number" ? ac.maxMessages : undefined,
};
}
// Parse coreMemory
let coreMemory: MemoryConfig["coreMemory"];
if (cfg.coreMemory && typeof cfg.coreMemory === "object" && !Array.isArray(cfg.coreMemory)) {
const bc = cfg.coreMemory as Record<string, unknown>;
assertAllowedKeys(bc, ["enabled", "maxEntries", "minImportance"], "coreMemory config");
coreMemory = {
enabled: bc.enabled === true,
maxEntries: typeof bc.maxEntries === "number" ? bc.maxEntries : 50,
minImportance: typeof bc.minImportance === "number" ? bc.minImportance : 0.5,
};
}
return {
embedding: {
provider: "openai",
@@ -121,9 +187,11 @@ export const memoryConfigSchema = {
apiKey: resolveEnvVars(embedding.apiKey),
},
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
autoCapture: cfg.autoCapture === true,
autoCapture: autoCapture ?? false,
autoRecall: cfg.autoRecall !== false,
captureMaxChars: captureMaxChars ?? DEFAULT_CAPTURE_MAX_CHARS,
// Default coreMemory to enabled for consistency with autoCapture/autoRecall
coreMemory: coreMemory ?? { enabled: true, maxEntries: 50, minImportance: 0.5 },
};
},
uiHints: {
@@ -143,19 +211,47 @@ export const memoryConfigSchema = {
placeholder: "~/.openclaw/memory/lancedb",
advanced: true,
},
autoCapture: {
"autoCapture.enabled": {
label: "Auto-Capture",
help: "Automatically capture important information from conversations",
help: "Automatically capture important information from conversations using LLM extraction",
},
"autoCapture.provider": {
label: "Capture LLM Provider",
placeholder: "openrouter",
advanced: true,
help: "LLM provider for memory extraction (openrouter or openai)",
},
"autoCapture.model": {
label: "Capture Model",
placeholder: "google/gemini-2.0-flash-001",
advanced: true,
help: "LLM model for memory extraction (use a fast/cheap model)",
},
"autoCapture.apiKey": {
label: "Capture API Key",
sensitive: true,
advanced: true,
help: "API key for capture LLM (defaults to OpenRouter key from provider config)",
},
autoRecall: {
label: "Auto-Recall",
help: "Automatically inject relevant memories into context",
},
captureMaxChars: {
label: "Capture Max Chars",
help: "Maximum message length eligible for auto-capture",
"coreMemory.enabled": {
label: "Core Memory",
help: "Inject core memories as virtual MEMORY.md at session start (replaces MEMORY.md file)",
},
"coreMemory.maxEntries": {
label: "Max Core Entries",
placeholder: "50",
advanced: true,
placeholder: String(DEFAULT_CAPTURE_MAX_CHARS),
help: "Maximum number of core memories to load",
},
"coreMemory.minImportance": {
label: "Min Core Importance",
placeholder: "0.5",
advanced: true,
help: "Minimum importance threshold for core memories (0-1)",
},
},
};

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Export memories from LanceDB for migration to memory-neo4j
*
* Usage:
* pnpm exec node export-memories.mjs [output-file.json]
*
* Default output: memories-export.json
*/
import * as lancedb from "@lancedb/lancedb";
import { writeFileSync } from "fs";
const LANCEDB_PATH = process.env.LANCEDB_PATH || "/home/tsukhani/.openclaw/memory/lancedb";
const AGENT_ID = process.env.AGENT_ID || "main";
const outputFile = process.argv[2] || "memories-export.json";
console.log("📦 Memory Export Tool (LanceDB)");
console.log(` LanceDB path: ${LANCEDB_PATH}`);
console.log(` Output: ${outputFile}`);
console.log("");
// Transform for neo4j format
function transformMemory(lanceEntry) {
const createdAtISO = new Date(lanceEntry.createdAt).toISOString();
return {
id: lanceEntry.id,
text: lanceEntry.text,
embedding: lanceEntry.vector,
importance: lanceEntry.importance,
category: lanceEntry.category,
createdAt: createdAtISO,
updatedAt: createdAtISO,
source: "import",
extractionStatus: "skipped",
agentId: AGENT_ID,
};
}
async function main() {
// Load from LanceDB
console.log("📥 Loading from LanceDB...");
const db = await lancedb.connect(LANCEDB_PATH);
const table = await db.openTable("memories");
const count = await table.countRows();
console.log(` Found ${count} memories`);
const memories = await table
.query()
.limit(count + 100)
.toArray();
console.log(` Loaded ${memories.length} memories`);
// Transform
console.log("🔄 Transforming...");
const transformed = memories.map(transformMemory);
// Stats
const stats = {};
transformed.forEach((m) => {
stats[m.category] = (stats[m.category] || 0) + 1;
});
console.log(" Categories:", stats);
// Export
console.log(`📤 Exporting to ${outputFile}...`);
const exportData = {
exportedAt: new Date().toISOString(),
sourcePlugin: "memory-lancedb",
targetPlugin: "memory-neo4j",
agentId: AGENT_ID,
vectorDim: transformed[0]?.embedding?.length || 1536,
count: transformed.length,
stats,
memories: transformed,
};
writeFileSync(outputFile, JSON.stringify(exportData, null, 2));
// Also write a preview without embeddings
const previewFile = outputFile.replace(".json", "-preview.json");
const preview = {
...exportData,
memories: transformed.map((m) => ({
...m,
embedding: `[${m.embedding?.length} dims]`,
})),
};
writeFileSync(previewFile, JSON.stringify(preview, null, 2));
console.log(`✅ Exported ${transformed.length} memories`);
console.log(
` Full export: ${outputFile} (${(JSON.stringify(exportData).length / 1024 / 1024).toFixed(2)} MB)`,
);
console.log(` Preview: ${previewFile}`);
}
main().catch((err) => {
console.error("❌ Error:", err.message);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import * as lancedb from "@lancedb/lancedb";
const db = await lancedb.connect("/home/tsukhani/.openclaw/memory/lancedb");
const tables = await db.tableNames();
console.log("Tables:", tables);
if (tables.includes("memories")) {
const table = await db.openTable("memories");
const count = await table.countRows();
console.log("Memory count:", count);
const all = await table.query().limit(200).toArray();
const stats = { preference: 0, fact: 0, decision: 0, entity: 0, other: 0, core: 0 };
all.forEach((e) => {
stats[e.category] = (stats[e.category] || 0) + 1;
});
console.log("\nCategory breakdown:", stats);
console.log("\nSample entries:");
all.slice(0, 5).forEach((e, i) => {
console.log(`${i + 1}. [${e.category}] ${(e.text || "").substring(0, 100)}...`);
console.log(` id: ${e.id}, importance: ${e.importance}, vectorDim: ${e.vector?.length}`);
});
}

View File

@@ -26,11 +26,21 @@
"label": "Auto-Recall",
"help": "Automatically inject relevant memories into context"
},
"captureMaxChars": {
"label": "Capture Max Chars",
"help": "Maximum message length eligible for auto-capture",
"coreMemory.enabled": {
"label": "Core Memory",
"help": "Inject core memories as virtual MEMORY.md at session start (replaces MEMORY.md file)"
},
"coreMemory.maxEntries": {
"label": "Max Core Entries",
"placeholder": "50",
"advanced": true,
"placeholder": "500"
"help": "Maximum number of core memories to load"
},
"coreMemory.minImportance": {
"label": "Min Core Importance",
"placeholder": "0.5",
"advanced": true,
"help": "Minimum importance threshold for core memories (0-1)"
}
},
"configSchema": {
@@ -60,10 +70,20 @@
"autoRecall": {
"type": "boolean"
},
"captureMaxChars": {
"type": "number",
"minimum": 100,
"maximum": 10000
"coreMemory": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"maxEntries": {
"type": "number"
},
"minImportance": {
"type": "number"
}
}
}
},
"required": ["embedding"]

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@@ -0,0 +1,252 @@
/**
* Attention gate — lightweight heuristic filter (phase 1 of memory pipeline).
*
* Rejects obvious noise without any LLM call, analogous to how the brain's
* sensory gating filters out irrelevant stimuli before they enter working
* memory. Everything that passes gets stored; the sleep cycle decides what
* matters.
*/
const NOISE_PATTERNS = [
// Greetings / acknowledgments (exact match, with optional punctuation)
/^(hi|hey|hello|yo|sup|ok|okay|sure|thanks|thank you|thx|ty|yep|yup|nope|no|yes|yeah|cool|nice|great|got it|sounds good|perfect|alright|fine|noted|ack|kk|k)\s*[.!?]*$/i,
// Two-word affirmations: "ok great", "sounds good", "yes please", etc.
/^(ok|okay|yes|yeah|yep|sure|no|nope|alright|right|fine|cool|nice|great)\s+(great|good|sure|thanks|please|ok|fine|cool|yeah|perfect|noted|absolutely|definitely|exactly)\s*[.!?]*$/i,
// Deictic: messages that are only pronouns/articles/common verbs — no standalone meaning
// e.g. "I need those", "let me do it", "ok let me test it out", "I got it"
/^(ok[,.]?\s+)?(i('ll|'m|'d|'ve)?\s+)?(just\s+)?(need|want|got|have|let|let's|let me|give me|send|do|did|try|check|see|look at|test|take|get|go|use)\s+(it|that|this|those|these|them|some|one|the|a|an|me|him|her|us)\s*(out|up|now|then|too|again|later|first|here|there|please)?\s*[.!?]*$/i,
// Short acknowledgments with trailing context: "ok, ..." / "yes, ..." when total is brief
/^(ok|okay|yes|yeah|yep|sure|no|nope|right|alright|fine|cool|nice|great|perfect)[,.]?\s+.{0,20}$/i,
// Conversational filler / noise phrases (standalone, with optional punctuation)
/^(hmm+|huh|haha|ha|lol|lmao|rofl|nah|meh|idk|brb|ttyl|omg|wow|whoa|welp|oops|ooh|aah|ugh|bleh|pfft|smh|ikr|tbh|imo|fwiw|np|nvm|nm|wut|wat|wha|heh|tsk|sigh|yay|woo+|boo|dang|darn|geez|gosh|sheesh|oof)\s*[.!?]*$/i,
// Single-word or near-empty
/^\S{0,3}$/,
// Pure emoji
/^[\p{Emoji}\s]+$/u,
// System/XML markup
/^<[a-z-]+>[\s\S]*<\/[a-z-]+>$/i,
// --- Session reset prompts (from /new and /reset commands) ---
/^A new session was started via/i,
// --- Raw chat messages with channel metadata (autocaptured noise) ---
/\[slack message id:/i,
/\[message_id:/i,
/\[telegram message id:/i,
// --- System infrastructure messages (never user-generated) ---
// Heartbeat prompts
/Read HEARTBEAT\.md if it exists/i,
// Pre-compaction flush prompts
/^Pre-compaction memory flush/i,
// System timestamp messages (cron outputs, reminders, exec reports)
/^System:\s*\[/i,
// Cron job wrappers
/^\[cron:[0-9a-f-]+/i,
// Gateway restart JSON payloads
/^GatewayRestart:\s*\{/i,
// Background task completion reports
/^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s.*\]\s*A background task/i,
// --- Conversation metadata that survived stripping ---
/^Conversation info\s*\(/i,
/^\[Queued messages/i,
// --- Cron delivery outputs & scheduled reminders ---
// Scheduled reminder injection text (appears mid-message)
/A scheduled reminder has been triggered/i,
// Cron delivery instruction to agent (summarize for user)
/Summarize this naturally for the user/i,
// Relay instruction from cron announcements
/Please relay this reminder to the user/i,
// Subagent completion announcements (date-stamped)
/^\[.*\d{4}-\d{2}-\d{2}.*\]\s*A sub-?agent task/i,
// Formatted urgency/priority reports (email summaries, briefings)
/(\*\*)?🔴\s*(URGENT|Priority)/i,
// Subagent findings header
/^Findings:\s*$/im,
// "Stats:" lines from subagent completions
/^Stats:\s*runtime\s/im,
];
/** Maximum message length — code dumps, logs, etc. are not memories. */
const MAX_CAPTURE_CHARS = 2000;
/** Minimum message length — too short to be meaningful. */
const MIN_CAPTURE_CHARS = 30;
/** Minimum word count — short contextual phrases lack standalone meaning. */
const MIN_WORD_COUNT = 8;
/** Shared checks applied by both user and assistant attention gates. */
function failsSharedGateChecks(trimmed: string): boolean {
// Injected context from the memory system itself
if (trimmed.includes("<relevant-memories>") || trimmed.includes("<core-memory-refresh>")) {
return true;
}
// Noise patterns
if (NOISE_PATTERNS.some((r) => r.test(trimmed))) {
return true;
}
// Excessive emoji (likely reaction, not substance)
const emojiCount = (
trimmed.match(/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1FA00}-\u{1FAFF}]/gu) ||
[]
).length;
if (emojiCount > 3) {
return true;
}
return false;
}
export function passesAttentionGate(text: string): boolean {
const trimmed = text.trim();
// Length bounds
if (trimmed.length < MIN_CAPTURE_CHARS || trimmed.length > MAX_CAPTURE_CHARS) {
return false;
}
// Word count — short phrases ("I need those") lack context for recall
const wordCount = trimmed.split(/\s+/).length;
if (wordCount < MIN_WORD_COUNT) {
return false;
}
if (failsSharedGateChecks(trimmed)) {
return false;
}
// Passes gate — retain for short-term storage
return true;
}
// ============================================================================
// Assistant attention gate — stricter filter for assistant messages
// ============================================================================
/** Maximum assistant message length — shorter than user to avoid code dumps. */
const MAX_ASSISTANT_CAPTURE_CHARS = 1000;
/** Minimum word count for assistant messages — higher than user. */
const MIN_ASSISTANT_WORD_COUNT = 10;
/**
* Patterns that reject assistant self-narration — play-by-play commentary
* that reads like thinking out loud rather than a conclusion or fact.
* These are the single biggest source of noise in auto-captured assistant memories.
*/
const ASSISTANT_NARRATION_PATTERNS = [
// "Let me ..." / "Now let me ..." / "I'll ..." action narration
/^(ok[,.]?\s+)?(now\s+)?let me\s+(check|look|see|try|run|start|test|read|update|verify|fix|search|process|create|build|set up|examine|investigate|query|fetch|pull|scan|clean|install|download|configure|make|select|click|type|fill|open|close|switch|send|post|submit|edit|change|add|remove|write|save|upload)/i,
// "I'll ..." action narration
/^I('ll| will)\s+(check|look|see|try|run|start|test|read|update|verify|fix|search|process|create|build|set up|examine|investigate|query|fetch|pull|scan|clean|install|download|configure|execute|help|handle|make|select|click|type|fill|open|close|switch|send|post|submit|edit|change|add|remove|write|save|upload|use|grab|get|do)/i,
// "Starting ..." / "Running ..." / "Processing ..." status updates
/^(starting|running|processing|checking|fetching|scanning|building|installing|downloading|configuring|executing|loading|updating|filling|selecting|clicking|typing|opening|closing|switching|navigating|uploading|saving|sending|posting|submitting)\s/i,
// "Good!" / "Great!" / "Perfect!" / "Done!" as opener followed by narration
/^(good|great|perfect|nice|excellent|awesome|done)[!.]?\s+(i |the |now |let |we |that |here)/i,
// Progress narration: "Now I have..." / "Now I can see..." / "Now let me..."
/^now\s+(i\s+(have|can|need|see|understand)|we\s+(have|can|need)|the\s|on\s)/i,
// Step narration: "Step 1:" / "**Step 1:**"
/^\*?\*?step\s+\d/i,
// Page/section progress narration: "Page 1 done!", "Page 3 — final page!"
/^Page\s+\d/i,
// Narration of what was found/done: "Found it." / "Found X." / "I see — ..."
/^(found it|found the|i see\s*[—–-])/i,
// Sub-agent task descriptions (workflow narration)
/^\[?(mon|tue|wed|thu|fri|sat|sun)\s+\d{4}-\d{2}-\d{2}/i,
// Context compaction self-announcements
/^🔄\s*\*?\*?context reset/i,
// Filename slug generation prompts (internal tool use)
/^based on this conversation,?\s*generate a short/i,
// --- Conversational filler responses (not knowledge) ---
// "I'm here" / "I am here" filler: "I'm here to help", "I am here and listening", etc.
/^I('m| am) here\b/i,
// Ready-state: "Sure, (just) tell me what you want..."
/^Sure[,!.]?\s+(just\s+)?(tell|let)\s+me/i,
// Observational UI narration: "I can see the picker", "I can see the button"
/^I can see\s/i,
// A sub-agent task report (quoted or inline)
/^A sub-?agent task\b/i,
// --- Injected system/voice context (not user knowledge) ---
// Voice mode formatting instructions injected into sessions
/^\[VOICE\s*(MODE|OUTPUT)/i,
/^\[voice[-\s]?context\]/i,
// Voice tag prefix
/^\[voice\]\s/i,
// --- Session completion summaries (ephemeral, not long-term knowledge) ---
// "Done ✅ ..." completion messages (assistant summarizing what it just did)
/^Done\s*[✅✓☑️]\s/i,
// "All good" / "All set" wrap-ups
/^All (good|set|done)[!.]/i,
// "Here's what changed" / "Summary of changes" (session-specific)
/^(here'?s\s+(what|the|a)\s+(changed?|summary|breakdown|recap))/i,
// --- Open proposals / action items (cause rogue actions when recalled) ---
// These are dangerous in memory: when auto-recalled, other sessions interpret
// them as active instructions and attempt to carry them out.
// "Want me to...?" / "Should I...?" / "Shall I...?" / "Would you like me to...?"
/want me to\s.+\?/i,
/should I\s.+\?/i,
/shall I\s.+\?/i,
/would you like me to\s.+\?/i,
// "Do you want me to...?"
/do you want me to\s.+\?/i,
// "Can I...?" / "May I...?" assistant proposals
/^(can|may) I\s.+\?/i,
// "Ready to...?" / "Proceed with...?"
/ready to\s.+\?/i,
/proceed with\s.+\?/i,
];
export function passesAssistantAttentionGate(text: string): boolean {
const trimmed = text.trim();
// Length bounds (stricter than user)
if (trimmed.length < MIN_CAPTURE_CHARS || trimmed.length > MAX_ASSISTANT_CAPTURE_CHARS) {
return false;
}
// Word count — higher threshold than user messages
const wordCount = trimmed.split(/\s+/).length;
if (wordCount < MIN_ASSISTANT_WORD_COUNT) {
return false;
}
// Reject messages that are mostly code (>50% inside triple-backtick fences)
const codeBlockRegex = /```[\s\S]*?```/g;
let codeChars = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockRegex.exec(trimmed)) !== null) {
codeChars += match[0].length;
}
if (codeChars > trimmed.length * 0.5) {
return false;
}
// Reject messages that are mostly tool output
if (
trimmed.includes("<tool_result>") ||
trimmed.includes("<tool_use>") ||
trimmed.includes("<function_call>")
) {
return false;
}
if (failsSharedGateChecks(trimmed)) {
return false;
}
// Assistant-specific narration patterns (play-by-play self-talk)
if (ASSISTANT_NARRATION_PATTERNS.some((r) => r.test(trimmed))) {
return false;
}
return true;
}

View File

@@ -0,0 +1,573 @@
/**
* Tests for the auto-capture pipeline: captureMessage and runAutoCapture.
*
* Tests the embed → dedup → rate → store pipeline including:
* - Pre-computed vector usage (batch embedding optimization)
* - Exact dedup (≥0.95 score band)
* - Semantic dedup (0.75-0.95 score band via LLM)
* - Importance pre-screening for assistant messages
* - Batch embedding in runAutoCapture
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExtractionConfig } from "./config.js";
import type { Embeddings } from "./embeddings.js";
import type { Neo4jMemoryClient } from "./neo4j-client.js";
import { _captureMessage as captureMessage, _runAutoCapture as runAutoCapture } from "./index.js";
// ============================================================================
// Mocks
// ============================================================================
const enabledConfig: ExtractionConfig = {
enabled: true,
apiKey: "test-key",
model: "test-model",
baseUrl: "https://test.ai/api/v1",
temperature: 0.0,
maxRetries: 0,
};
const disabledConfig: ExtractionConfig = {
...enabledConfig,
enabled: false,
};
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
function createMockDb(overrides?: Partial<Neo4jMemoryClient>): Neo4jMemoryClient {
return {
findSimilar: vi.fn().mockResolvedValue([]),
storeMemory: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as Neo4jMemoryClient;
}
function createMockEmbeddings(overrides?: Partial<Embeddings>): Embeddings {
return {
embed: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
embedBatch: vi.fn().mockResolvedValue([[0.1, 0.2, 0.3]]),
...overrides,
} as unknown as Embeddings;
}
// ============================================================================
// captureMessage
// ============================================================================
describe("captureMessage", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("should store a new memory when no duplicates exist", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
// Mock rateImportance (LLM call via fetch)
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 7 }) } }],
}),
});
const result = await captureMessage(
"I prefer TypeScript over JavaScript",
"auto-capture",
0.5,
1.0,
"test-agent",
"session-1",
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(result.stored).toBe(true);
expect(result.semanticDeduped).toBe(false);
expect(db.storeMemory).toHaveBeenCalledOnce();
expect(embeddings.embed).toHaveBeenCalledWith("I prefer TypeScript over JavaScript");
});
it("should use pre-computed vector when provided", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
const precomputedVector = [0.5, 0.6, 0.7];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 7 }) } }],
}),
});
const result = await captureMessage(
"test text",
"auto-capture",
0.5,
1.0,
"test-agent",
undefined,
db,
embeddings,
enabledConfig,
mockLogger,
precomputedVector,
);
expect(result.stored).toBe(true);
// Should NOT call embed() since pre-computed vector was provided
expect(embeddings.embed).not.toHaveBeenCalled();
// Should use the pre-computed vector for findSimilar
expect(db.findSimilar).toHaveBeenCalledWith(precomputedVector, 0.75, 3, "test-agent");
});
it("should skip storage when exact duplicate found (score >= 0.95)", async () => {
const db = createMockDb({
findSimilar: vi
.fn()
.mockResolvedValue([{ id: "existing-1", text: "duplicate text", score: 0.97 }]),
});
const embeddings = createMockEmbeddings();
const result = await captureMessage(
"duplicate text",
"auto-capture",
0.5,
1.0,
"test-agent",
undefined,
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(result.stored).toBe(false);
expect(result.semanticDeduped).toBe(false);
expect(db.storeMemory).not.toHaveBeenCalled();
});
it("should semantic dedup when candidate in 0.75-0.95 band is LLM-confirmed duplicate", async () => {
const db = createMockDb({
findSimilar: vi
.fn()
.mockResolvedValue([{ id: "candidate-1", text: "User prefers TypeScript", score: 0.88 }]),
});
const embeddings = createMockEmbeddings();
// First call: rateImportance, second call: isSemanticDuplicate
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
// rateImportance response
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 7 }) } }],
}),
});
}
// isSemanticDuplicate response
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: JSON.stringify({
verdict: "duplicate",
reason: "same preference",
}),
},
},
],
}),
});
});
const result = await captureMessage(
"I like TypeScript",
"auto-capture",
0.5,
1.0,
"test-agent",
undefined,
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(result.stored).toBe(false);
expect(result.semanticDeduped).toBe(true);
expect(db.storeMemory).not.toHaveBeenCalled();
});
it("should skip importance check when extraction is disabled", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
// With extraction disabled, rateImportance returns 0.5 fallback,
// so the threshold check is skipped entirely
const result = await captureMessage(
"some text to store",
"auto-capture",
0.5,
1.0,
"test-agent",
undefined,
db,
embeddings,
disabledConfig,
mockLogger,
);
expect(result.stored).toBe(true);
expect(db.storeMemory).toHaveBeenCalledOnce();
// Verify stored with fallback importance * discount
const storeCall = (db.storeMemory as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(storeCall.importance).toBe(0.5); // 0.5 fallback * 1.0 discount
expect(storeCall.extractionStatus).toBe("skipped");
});
it("should apply importance discount for assistant messages", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
// For assistant messages, importance is rated first
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 8 }) } }],
}),
});
const result = await captureMessage(
"Here's what I know about Neo4j graph databases...",
"auto-capture-assistant",
0.8, // higher threshold for assistant
0.75, // 25% discount
"test-agent",
undefined,
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(result.stored).toBe(true);
const storeCall = (db.storeMemory as ReturnType<typeof vi.fn>).mock.calls[0][0];
// importance 0.8 (score 8/10) * 0.75 discount ≈ 0.6
expect(storeCall.importance).toBeCloseTo(0.6);
expect(storeCall.source).toBe("auto-capture-assistant");
});
it("should reject assistant messages below importance threshold", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
// Low importance score
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 3 }) } }],
}),
});
const result = await captureMessage(
"Sure, I can help with that.",
"auto-capture-assistant",
0.8, // threshold 0.8
0.75,
"test-agent",
undefined,
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(result.stored).toBe(false);
// Should not even embed since importance pre-screen failed
expect(embeddings.embed).not.toHaveBeenCalled();
expect(db.storeMemory).not.toHaveBeenCalled();
});
it("should reject user messages below importance threshold", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
// Low importance score
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 2 }) } }],
}),
});
const result = await captureMessage(
"okay thanks",
"auto-capture",
0.5, // threshold 0.5
1.0,
"test-agent",
undefined,
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(result.stored).toBe(false);
expect(db.storeMemory).not.toHaveBeenCalled();
});
});
// ============================================================================
// runAutoCapture
// ============================================================================
describe("runAutoCapture", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("should batch-embed all retained messages at once", async () => {
const db = createMockDb();
const embedBatchMock = vi.fn().mockResolvedValue([
[0.1, 0.2],
[0.3, 0.4],
]);
const embeddings = createMockEmbeddings({ embedBatch: embedBatchMock });
// Mock rateImportance calls
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 7 }) } }],
}),
});
const messages = [
{
role: "user",
content: "I prefer TypeScript over JavaScript for backend development",
},
{
role: "assistant",
content:
"TypeScript is great for type safety and developer experience, especially with Node.js projects",
},
];
await runAutoCapture(
messages,
"test-agent",
"session-1",
db,
embeddings,
enabledConfig,
mockLogger,
);
// Should call embedBatch once with both texts
expect(embedBatchMock).toHaveBeenCalledOnce();
const batchTexts = embedBatchMock.mock.calls[0][0];
expect(batchTexts.length).toBe(2);
});
it("should not call embedBatch when no messages pass the gate", async () => {
const db = createMockDb();
const embedBatchMock = vi.fn().mockResolvedValue([]);
const embeddings = createMockEmbeddings({ embedBatch: embedBatchMock });
// Short messages that won't pass attention gate
const messages = [
{ role: "user", content: "ok" },
{ role: "assistant", content: "yes" },
];
await runAutoCapture(
messages,
"test-agent",
"session-1",
db,
embeddings,
enabledConfig,
mockLogger,
);
expect(embedBatchMock).not.toHaveBeenCalled();
expect(db.storeMemory).not.toHaveBeenCalled();
});
it("should handle empty messages array", async () => {
const db = createMockDb();
const embeddings = createMockEmbeddings();
await runAutoCapture([], "test-agent", undefined, db, embeddings, enabledConfig, mockLogger);
expect(db.storeMemory).not.toHaveBeenCalled();
});
it("should continue processing if one message fails", async () => {
const db = createMockDb();
// First embed call fails, second succeeds
let embedCallCount = 0;
const findSimilarMock = vi.fn().mockImplementation(() => {
embedCallCount++;
if (embedCallCount === 1) {
return Promise.reject(new Error("DB connection failed"));
}
return Promise.resolve([]);
});
const embedBatchMock = vi.fn().mockResolvedValue([
[0.1, 0.2],
[0.3, 0.4],
]);
const dbWithError = createMockDb({
findSimilar: findSimilarMock,
});
const embeddings = createMockEmbeddings({ embedBatch: embedBatchMock });
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 7 }) } }],
}),
});
const messages = [
{
role: "user",
content: "First message that is long enough to pass the attention gate filter",
},
{
role: "user",
content: "Second message that is also long enough to pass the attention gate",
},
];
// Should not throw — errors are caught per-message
await runAutoCapture(
messages,
"test-agent",
"session-1",
dbWithError,
embeddings,
enabledConfig,
mockLogger,
);
// The second message should still have been attempted
expect(findSimilarMock).toHaveBeenCalledTimes(2);
});
it("should use different thresholds for user vs assistant messages", async () => {
const db = createMockDb();
const storeMemoryMock = vi.fn().mockResolvedValue(undefined);
const dbWithStore = createMockDb({ storeMemory: storeMemoryMock });
const embedBatchMock = vi.fn().mockResolvedValue([
[0.1, 0.2],
[0.3, 0.4],
]);
const embeddings = createMockEmbeddings({ embedBatch: embedBatchMock });
// Always return high importance so both pass
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: JSON.stringify({ score: 9 }) } }],
}),
});
const messages = [
{
role: "user",
content: "I really love working with graph databases like Neo4j for my projects",
},
{
role: "assistant",
content:
"Graph databases like Neo4j excel at modeling connected data and relationship queries",
},
];
await runAutoCapture(
messages,
"test-agent",
"session-1",
dbWithStore,
embeddings,
enabledConfig,
mockLogger,
);
// Both should be stored
const storeCalls = storeMemoryMock.mock.calls;
if (storeCalls.length === 2) {
// User message: importance * 1.0 discount
expect(storeCalls[0][0].source).toBe("auto-capture");
// Assistant message: importance * 0.75 discount
expect(storeCalls[1][0].source).toBe("auto-capture-assistant");
expect(storeCalls[1][0].importance).toBeLessThan(storeCalls[0][0].importance);
}
});
it("should log capture errors without throwing", async () => {
const embedBatchMock = vi.fn().mockRejectedValue(new Error("embedding service down"));
const embeddings = createMockEmbeddings({ embedBatch: embedBatchMock });
const db = createMockDb();
const messages = [
{
role: "user",
content: "A long enough message to pass the attention gate for testing purposes",
},
];
// Should not throw
await runAutoCapture(
messages,
"test-agent",
"session-1",
db,
embeddings,
enabledConfig,
mockLogger,
);
// Should have logged the error
expect(mockLogger.warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,817 @@
/**
* CLI command registration for memory-neo4j.
*
* Registers the `openclaw memory neo4j` subcommand group with commands:
* - list: List memory counts by agent and category
* - search: Search memories via hybrid search
* - stats: Show memory statistics and configuration
* - sleep: Run sleep cycle (six-phase memory consolidation)
* - index: Re-embed all memories after changing embedding model
* - cleanup: Retroactively apply attention gate to stored memories
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { ExtractionConfig, MemoryNeo4jConfig } from "./config.js";
import type { Embeddings } from "./embeddings.js";
import type { Neo4jMemoryClient } from "./neo4j-client.js";
import { passesAttentionGate } from "./attention-gate.js";
import { stripMessageWrappers } from "./message-utils.js";
import { hybridSearch } from "./search.js";
import { runSleepCycle } from "./sleep-cycle.js";
export type CliDeps = {
db: Neo4jMemoryClient;
embeddings: Embeddings;
cfg: MemoryNeo4jConfig;
extractionConfig: ExtractionConfig;
vectorDim: number;
};
/**
* Register the `openclaw memory neo4j` CLI subcommand group.
*/
export function registerCli(api: OpenClawPluginApi, deps: CliDeps): void {
const { db, embeddings, cfg, extractionConfig, vectorDim } = deps;
api.registerCli(
({ program }) => {
// Find existing memory command or create fallback
let memoryCmd = program.commands.find((cmd) => cmd.name() === "memory");
if (!memoryCmd) {
// Fallback if core memory CLI not registered yet
memoryCmd = program.command("memory").description("Memory commands");
}
// Add neo4j memory subcommand group
const memory = memoryCmd.command("neo4j").description("Neo4j graph memory commands");
memory
.command("list")
.description("List memories grouped by agent and category")
.option("--agent <id>", "Filter by agent id")
.option("--category <name>", "Filter by category")
.option("--limit <n>", "Max memories per category (default: 20)")
.option("--json", "Output as JSON")
.action(
async (opts: { agent?: string; category?: string; limit?: string; json?: boolean }) => {
try {
await db.ensureInitialized();
const perCategoryLimit = opts.limit ? parseInt(opts.limit, 10) : 20;
if (Number.isNaN(perCategoryLimit) || perCategoryLimit <= 0) {
console.error("Error: --limit must be greater than 0");
process.exitCode = 1;
return;
}
// Build query with optional filters
const conditions: string[] = [];
const params: Record<string, unknown> = {};
if (opts.agent) {
conditions.push("m.agentId = $agentId");
params.agentId = opts.agent;
}
if (opts.category) {
conditions.push("m.category = $category");
params.category = opts.category;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = await db.runQuery<{
agentId: string;
category: string;
id: string;
text: string;
importance: number;
createdAt: string;
source: string;
}>(
`MATCH (m:Memory) ${where}
WITH m.agentId AS agentId, m.category AS category, m
ORDER BY m.importance DESC
WITH agentId, category, collect({
id: m.id, text: m.text, importance: m.importance,
createdAt: m.createdAt, source: coalesce(m.source, 'unknown')
}) AS memories
UNWIND memories[0..${perCategoryLimit}] AS mem
RETURN agentId, category,
mem.id AS id, mem.text AS text,
mem.importance AS importance,
mem.createdAt AS createdAt,
mem.source AS source
ORDER BY agentId, category, importance DESC`,
params,
);
if (opts.json) {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("No memories found.");
return;
}
// Group by agent → category → memories
const byAgent = new Map<
string,
Map<
string,
Array<{
id: string;
text: string;
importance: number;
createdAt: string;
source: string;
}>
>
>();
for (const row of rows) {
const agent = (row.agentId as string) ?? "default";
const cat = (row.category as string) ?? "other";
if (!byAgent.has(agent)) byAgent.set(agent, new Map());
const catMap = byAgent.get(agent)!;
if (!catMap.has(cat)) catMap.set(cat, []);
catMap.get(cat)!.push({
id: row.id as string,
text: row.text as string,
importance: row.importance as number,
createdAt: row.createdAt as string,
source: row.source as string,
});
}
const impBar = (ratio: number) => {
const W = 10;
const filled = Math.round(ratio * W);
return "█".repeat(filled) + "░".repeat(W - filled);
};
for (const [agentId, categories] of byAgent) {
const agentTotal = [...categories.values()].reduce((s, m) => s + m.length, 0);
console.log(`\n┌─ ${agentId} (${agentTotal} shown)`);
for (const [category, memories] of categories) {
console.log(`\n│ ── ${category} (${memories.length}) ──`);
for (const mem of memories) {
const pct = ((mem.importance * 100).toFixed(0) + "%").padStart(4);
const preview = mem.text.length > 72 ? `${mem.text.slice(0, 69)}...` : mem.text;
console.log(`${impBar(mem.importance)} ${pct} ${preview}`);
}
}
console.log("└");
}
console.log("");
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
},
);
memory
.command("search")
.description("Search memories")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", "5")
.option("--agent <id>", "Agent id (default: default)")
.action(async (query: string, opts: { limit: string; agent?: string }) => {
try {
const results = await hybridSearch(
db,
embeddings,
query,
parseInt(opts.limit, 10),
opts.agent ?? "default",
extractionConfig.enabled,
{ graphSearchDepth: cfg.graphSearchDepth },
);
const output = results.map((r) => ({
id: r.id,
text: r.text,
category: r.category,
importance: r.importance,
score: r.score,
}));
console.log(JSON.stringify(output, null, 2));
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("stats")
.description("Show memory statistics and configuration")
.action(async () => {
try {
await db.ensureInitialized();
const stats = await db.getMemoryStats();
const total = stats.reduce((sum, s) => sum + s.count, 0);
console.log("\nMemory (Neo4j) Statistics");
console.log("─────────────────────────");
console.log(`Total memories: ${total}`);
console.log(`Neo4j URI: ${cfg.neo4j.uri}`);
console.log(`Embedding: ${cfg.embedding.provider}/${cfg.embedding.model}`);
console.log(
`Extraction: ${extractionConfig.enabled ? extractionConfig.model : "disabled"}`,
);
console.log(`Auto-capture: ${cfg.autoCapture ? "enabled" : "disabled"}`);
console.log(`Auto-recall: ${cfg.autoRecall ? "enabled" : "disabled"}`);
console.log(`Core memory: ${cfg.coreMemory.enabled ? "enabled" : "disabled"}`);
if (stats.length > 0) {
const BAR_WIDTH = 20;
const bar = (ratio: number) => {
const filled = Math.round(ratio * BAR_WIDTH);
return "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
};
// Group by agentId
const byAgent = new Map<
string,
Array<{ category: string; count: number; avgImportance: number }>
>();
for (const row of stats) {
const list = byAgent.get(row.agentId) || [];
list.push({
category: row.category,
count: row.count,
avgImportance: row.avgImportance,
});
byAgent.set(row.agentId, list);
}
for (const [agentId, categories] of byAgent) {
const agentTotal = categories.reduce((sum, c) => sum + c.count, 0);
const maxCatCount = Math.max(...categories.map((c) => c.count));
const catLabelLen = Math.max(...categories.map((c) => c.category.length));
console.log(`\n┌─ ${agentId} (${agentTotal} memories)`);
console.log("│");
console.log(
`${"Category".padEnd(catLabelLen)} ${"Count".padStart(5)} ${"".padEnd(BAR_WIDTH)} ${"Importance".padStart(10)}`,
);
console.log(`${"─".repeat(catLabelLen + 5 + BAR_WIDTH * 2 + 18)}`);
for (const { category, count, avgImportance } of categories) {
const cat = category.padEnd(catLabelLen);
const cnt = String(count).padStart(5);
const pct = ((avgImportance * 100).toFixed(0) + "%").padStart(10);
console.log(
`${cat} ${cnt} ${bar(count / maxCatCount)} ${pct} ${bar(avgImportance)}`,
);
}
console.log("└");
}
console.log(`\nAgents: ${byAgent.size} (${[...byAgent.keys()].join(", ")})`);
}
console.log("");
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("sleep")
.description("Run sleep cycle — consolidate memories")
.option("--agent <id>", "Agent id (default: all agents)")
.option("--dedup-threshold <n>", "Vector similarity threshold for dedup (default: 0.95)")
.option("--decay-threshold <n>", "Decay score threshold for pruning (default: 0.1)")
.option("--decay-half-life <days>", "Base half-life in days (default: 30)")
.option("--batch-size <n>", "Extraction batch size (default: 50)")
.option("--delay <ms>", "Delay between extraction batches in ms (default: 1000)")
.option("--max-semantic-pairs <n>", "Max LLM-checked semantic dedup pairs (default: 500)")
.option("--concurrency <n>", "Parallel LLM calls — match OLLAMA_NUM_PARALLEL (default: 8)")
.option(
"--skip-semantic",
"Skip LLM-based semantic dedup (Phase 1b) and conflict detection (Phase 1c)",
)
.option("--workspace <dir>", "Workspace directory for TASKS.md cleanup")
.option("--report", "Show quality metrics after sleep cycle completes")
.action(
async (opts: {
agent?: string;
dedupThreshold?: string;
decayThreshold?: string;
decayHalfLife?: string;
batchSize?: string;
delay?: string;
maxSemanticPairs?: string;
concurrency?: string;
skipSemantic?: boolean;
workspace?: string;
report?: boolean;
}) => {
console.log("\n🌙 Memory Sleep Cycle");
console.log("═════════════════════════════════════════════════════════════");
console.log("Multi-phase memory consolidation:\n");
console.log(" Phase 1: Deduplication — Merge near-duplicate memories");
console.log(
" Phase 1b: Semantic Dedup — LLM-based paraphrase detection (0.750.95 band)",
);
console.log(" Phase 1c: Conflict Detection — Resolve contradictory memories");
console.log(" Phase 1d: Entity Dedup — Merge duplicate entity nodes");
console.log(" Phase 2: Extraction — Extract entities and categorize");
console.log(" Phase 2b: Retroactive Tagging — Tag memories missing topic tags");
console.log(" Phase 3: Decay & Pruning — Remove stale low-importance memories");
console.log(" Phase 4: Orphan Cleanup — Remove disconnected nodes");
console.log(" Phase 5: Noise Cleanup — Remove dangerous pattern memories");
console.log(" Phase 5b: Credential Scan — Remove memories with leaked secrets");
console.log(" Phase 6: Task Ledger Cleanup — Archive stale tasks in TASKS.md\n");
try {
// Validate sleep cycle CLI parameters before running
const batchSize = opts.batchSize ? parseInt(opts.batchSize, 10) : undefined;
const delay = opts.delay ? parseInt(opts.delay, 10) : undefined;
const decayHalfLife = opts.decayHalfLife
? parseInt(opts.decayHalfLife, 10)
: undefined;
const decayThreshold = opts.decayThreshold
? parseFloat(opts.decayThreshold)
: undefined;
if (batchSize != null && (Number.isNaN(batchSize) || batchSize <= 0)) {
console.error("Error: --batch-size must be greater than 0");
process.exitCode = 1;
return;
}
if (delay != null && (Number.isNaN(delay) || delay < 0)) {
console.error("Error: --delay must be >= 0");
process.exitCode = 1;
return;
}
if (decayHalfLife != null && (Number.isNaN(decayHalfLife) || decayHalfLife <= 0)) {
console.error("Error: --decay-half-life must be greater than 0");
process.exitCode = 1;
return;
}
if (
decayThreshold != null &&
(Number.isNaN(decayThreshold) || decayThreshold < 0 || decayThreshold > 1)
) {
console.error("Error: --decay-threshold must be between 0 and 1");
process.exitCode = 1;
return;
}
const maxSemanticPairs = opts.maxSemanticPairs
? parseInt(opts.maxSemanticPairs, 10)
: undefined;
if (
maxSemanticPairs != null &&
(Number.isNaN(maxSemanticPairs) || maxSemanticPairs <= 0)
) {
console.error("Error: --max-semantic-pairs must be greater than 0");
process.exitCode = 1;
return;
}
const concurrency = opts.concurrency ? parseInt(opts.concurrency, 10) : undefined;
if (concurrency != null && (Number.isNaN(concurrency) || concurrency <= 0)) {
console.error("Error: --concurrency must be greater than 0");
process.exitCode = 1;
return;
}
await db.ensureInitialized();
// Resolve workspace dir for task ledger cleanup
const resolvedWorkspace = opts.workspace?.trim() || undefined;
const result = await runSleepCycle(db, embeddings, extractionConfig, api.logger, {
agentId: opts.agent,
dedupThreshold: opts.dedupThreshold ? parseFloat(opts.dedupThreshold) : undefined,
skipSemanticDedup: opts.skipSemantic === true,
maxSemanticDedupPairs: maxSemanticPairs,
llmConcurrency: concurrency,
decayRetentionThreshold: decayThreshold,
decayBaseHalfLifeDays: decayHalfLife,
decayCurves: Object.keys(cfg.decayCurves).length > 0 ? cfg.decayCurves : undefined,
extractionBatchSize: batchSize,
extractionDelayMs: delay,
workspaceDir: resolvedWorkspace,
onPhaseStart: (phase) => {
const phaseNames: Record<string, string> = {
dedup: "Phase 1: Deduplication",
semanticDedup: "Phase 1b: Semantic Deduplication",
conflict: "Phase 1c: Conflict Detection",
entityDedup: "Phase 1d: Entity Deduplication",
extraction: "Phase 2: Extraction",
retroactiveTagging: "Phase 2b: Retroactive Tagging",
decay: "Phase 3: Decay & Pruning",
cleanup: "Phase 4: Orphan Cleanup",
noiseCleanup: "Phase 5: Noise Cleanup",
credentialScan: "Phase 5b: Credential Scan",
taskLedger: "Phase 6: Task Ledger Cleanup",
};
console.log(`\n▶ ${phaseNames[phase] ?? phase}`);
console.log("─────────────────────────────────────────────────────────────");
},
onProgress: (_phase, message) => {
console.log(` ${message}`);
},
});
console.log("\n═════════════════════════════════════════════════════════════");
console.log(`✅ Sleep cycle complete in ${(result.durationMs / 1000).toFixed(1)}s`);
console.log("─────────────────────────────────────────────────────────────");
console.log(
` Deduplication: ${result.dedup.clustersFound} clusters → ${result.dedup.memoriesMerged} merged`,
);
console.log(
` Conflicts: ${result.conflict.pairsFound} pairs, ${result.conflict.resolved} resolved, ${result.conflict.invalidated} invalidated`,
);
console.log(
` Semantic Dedup: ${result.semanticDedup.pairsChecked} pairs checked, ${result.semanticDedup.duplicatesMerged} merged`,
);
console.log(` Decay/Pruning: ${result.decay.memoriesPruned} memories pruned`);
console.log(
` Extraction: ${result.extraction.succeeded}/${result.extraction.total} extracted` +
(result.extraction.failed > 0 ? ` (${result.extraction.failed} failed)` : ""),
);
console.log(
` Retro-Tagging: ${result.retroactiveTagging.tagged}/${result.retroactiveTagging.total} tagged` +
(result.retroactiveTagging.failed > 0
? ` (${result.retroactiveTagging.failed} failed)`
: ""),
);
console.log(
` Cleanup: ${result.cleanup.entitiesRemoved} entities, ${result.cleanup.tagsRemoved} tags removed`,
);
console.log(
` Task Ledger: ${result.taskLedger.archivedCount} stale tasks archived` +
(result.taskLedger.archivedIds.length > 0
? ` (${result.taskLedger.archivedIds.join(", ")})`
: ""),
);
if (result.aborted) {
console.log("\n⚠ Sleep cycle was aborted before completion.");
}
// Quality report (optional)
if (opts.report) {
console.log("\n═════════════════════════════════════════════════════════════");
console.log("📊 Quality Report");
console.log("─────────────────────────────────────────────────────────────");
try {
// Extraction coverage
const statusCounts = await db.countByExtractionStatus(opts.agent);
const totalMems =
statusCounts.pending +
statusCounts.complete +
statusCounts.failed +
statusCounts.skipped;
const coveragePct =
totalMems > 0 ? ((statusCounts.complete / totalMems) * 100).toFixed(1) : "0.0";
console.log(
`\n Extraction Coverage: ${coveragePct}% (${statusCounts.complete}/${totalMems})`,
);
console.log(
` pending=${statusCounts.pending} complete=${statusCounts.complete} failed=${statusCounts.failed} skipped=${statusCounts.skipped}`,
);
// Entity graph stats
const graphStats = await db.getEntityGraphStats(opts.agent);
console.log(`\n Entity Graph:`);
console.log(
` Entities: ${graphStats.entityCount} Mentions: ${graphStats.mentionCount} Density: ${graphStats.density.toFixed(2)}`,
);
// Decay distribution
const decayDist = await db.getDecayDistribution(opts.agent);
if (decayDist.length > 0) {
const maxCount = Math.max(...decayDist.map((d) => d.count));
const BAR_W = 20;
console.log(`\n Decay Distribution:`);
for (const { bucket, count } of decayDist) {
const filled = maxCount > 0 ? Math.round((count / maxCount) * BAR_W) : 0;
const bar = "█".repeat(filled) + "░".repeat(BAR_W - filled);
console.log(` ${bucket.padEnd(13)} ${bar} ${count}`);
}
}
} catch (reportErr) {
console.log(`\n ⚠️ Could not generate quality report: ${String(reportErr)}`);
}
}
console.log("");
} catch (err) {
console.error(
`\n❌ Sleep cycle failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
},
);
memory
.command("index")
.description(
"Re-embed all memories and entities — use after changing embedding model/provider",
)
.option("--batch-size <n>", "Embedding batch size (default: 50)")
.action(async (opts: { batchSize?: string }) => {
const batchSize = opts.batchSize ? parseInt(opts.batchSize, 10) : 50;
if (Number.isNaN(batchSize) || batchSize <= 0) {
console.error("Error: --batch-size must be greater than 0");
process.exitCode = 1;
return;
}
console.log("\nMemory Neo4j — Reindex Embeddings");
console.log("═════════════════════════════════════════════════════════════");
console.log(`Model: ${cfg.embedding.provider}/${cfg.embedding.model}`);
console.log(`Dimensions: ${vectorDim}`);
console.log(`Batch size: ${batchSize}\n`);
try {
const startedAt = Date.now();
const result = await db.reindex((texts) => embeddings.embedBatch(texts), {
batchSize,
onProgress: (phase, done, total) => {
if (phase === "drop-indexes" && done === 0) {
console.log("▶ Dropping old vector index…");
} else if (phase === "memories") {
console.log(` Memories: ${done}/${total}`);
} else if (phase === "create-indexes" && done === 0) {
console.log("▶ Recreating vector index…");
}
},
});
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log("\n═════════════════════════════════════════════════════════════");
console.log(`✅ Reindex complete in ${elapsed}s — ${result.memories} memories`);
console.log("");
} catch (err) {
console.error(
`\n❌ Reindex failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
}
});
memory
.command("cleanup")
.description(
"Retroactively apply the attention gate — find and remove low-substance memories",
)
.option("--execute", "Actually delete (default: dry-run preview)")
.option("--all", "Include explicitly-stored memories (default: auto-capture only)")
.option("--agent <id>", "Only clean up memories for a specific agent")
.action(async (opts: { execute?: boolean; all?: boolean; agent?: string }) => {
try {
await db.ensureInitialized();
// Fetch memories — by default only auto-capture (explicit stores are trusted)
const conditions: string[] = [];
if (!opts.all) {
conditions.push("m.source = 'auto-capture'");
}
if (opts.agent) {
conditions.push("m.agentId = $agentId");
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const allMemories = await db.runQuery<{
id: string;
text: string;
source: string;
}>(
`MATCH (m:Memory) ${where}
RETURN m.id AS id, m.text AS text, COALESCE(m.source, 'unknown') AS source
ORDER BY m.createdAt ASC`,
opts.agent ? { agentId: opts.agent } : {},
);
// Strip channel metadata wrappers (same as the real pipeline) then gate
const noise: Array<{ id: string; text: string; source: string }> = [];
for (const mem of allMemories) {
const stripped = stripMessageWrappers(mem.text);
if (!passesAttentionGate(stripped)) {
noise.push(mem);
}
}
if (noise.length === 0) {
console.log("\nNo low-substance memories found. Everything passes the gate.");
return;
}
console.log(
`\nFound ${noise.length}/${allMemories.length} memories that fail the attention gate:\n`,
);
for (const mem of noise) {
const preview = mem.text.length > 80 ? `${mem.text.slice(0, 77)}...` : mem.text;
console.log(` [${mem.source}] "${preview}"`);
}
if (!opts.execute) {
console.log(
`\nDry run — ${noise.length} memories would be removed. Re-run with --execute to delete.\n`,
);
return;
}
// Delete in batch
const deleted = await db.pruneMemories(noise.map((m) => m.id));
console.log(`\nDeleted ${deleted} low-substance memories.\n`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
memory
.command("health")
.description("Memory system health dashboard")
.option("--agent <id>", "Scope to a specific agent")
.option("--json", "Output all sections as JSON")
.action(async (opts: { agent?: string; json?: boolean }) => {
try {
await db.ensureInitialized();
const agentId = opts.agent;
// Gather all data in parallel
const [
memoryStats,
totalCount,
statusCounts,
graphStats,
decayDist,
orphanEntities,
orphanTags,
singleUseTags,
] = await Promise.all([
db.getMemoryStats(),
db.countMemories(agentId),
db.countByExtractionStatus(agentId),
db.getEntityGraphStats(agentId),
db.getDecayDistribution(agentId),
db.findOrphanEntities(500),
db.findOrphanTags(500),
db.findSingleUseTags(14, 500),
]);
// Filter stats by agent if specified
const filteredStats = agentId
? memoryStats.filter((s) => s.agentId === agentId)
: memoryStats;
if (opts.json) {
const totalExtraction =
statusCounts.pending +
statusCounts.complete +
statusCounts.failed +
statusCounts.skipped;
console.log(
JSON.stringify(
{
memoryOverview: {
total: totalCount,
byAgentCategory: filteredStats,
},
extractionHealth: {
...statusCounts,
total: totalExtraction,
coveragePercent:
totalExtraction > 0
? Number(((statusCounts.complete / totalExtraction) * 100).toFixed(1))
: 0,
},
entityGraph: {
...graphStats,
orphanCount: orphanEntities.length,
},
tagHealth: {
orphanCount: orphanTags.length,
singleUseCount: singleUseTags.length,
},
decayDistribution: decayDist,
},
null,
2,
),
);
return;
}
const BAR_W = 20;
const bar = (ratio: number) => {
const filled = Math.round(Math.min(1, Math.max(0, ratio)) * BAR_W);
return "█".repeat(filled) + "░".repeat(BAR_W - filled);
};
console.log("\n╔═══════════════════════════════════════════════════════════╗");
console.log("║ Memory (Neo4j) Health Dashboard ║");
if (agentId) {
console.log(`║ Agent: ${agentId.padEnd(49)}`);
}
console.log("╚═══════════════════════════════════════════════════════════╝");
// Section 1: Memory Overview
console.log("\n┌─ Memory Overview");
console.log("│");
console.log(`│ Total: ${totalCount} memories`);
if (filteredStats.length > 0) {
// Group by agent
const byAgent = new Map<
string,
Array<{ category: string; count: number; avgImportance: number }>
>();
for (const row of filteredStats) {
const list = byAgent.get(row.agentId) || [];
list.push({
category: row.category,
count: row.count,
avgImportance: row.avgImportance,
});
byAgent.set(row.agentId, list);
}
for (const [agent, categories] of byAgent) {
const agentTotal = categories.reduce((s, c) => s + c.count, 0);
const maxCat = Math.max(...categories.map((c) => c.count));
console.log(``);
console.log(`${agent} (${agentTotal}):`);
for (const { category, count } of categories) {
const ratio = maxCat > 0 ? count / maxCat : 0;
console.log(`${category.padEnd(12)} ${bar(ratio)} ${count}`);
}
}
}
console.log("└");
// Section 2: Extraction Health
const totalExtraction =
statusCounts.pending +
statusCounts.complete +
statusCounts.failed +
statusCounts.skipped;
const coveragePct =
totalExtraction > 0
? ((statusCounts.complete / totalExtraction) * 100).toFixed(1)
: "0.0";
console.log("\n┌─ Extraction Health");
console.log("│");
console.log(
`│ Coverage: ${coveragePct}% (${statusCounts.complete}/${totalExtraction})`,
);
console.log(``);
const statusEntries: Array<[string, number]> = [
["pending", statusCounts.pending],
["complete", statusCounts.complete],
["failed", statusCounts.failed],
["skipped", statusCounts.skipped],
];
const maxStatus = Math.max(...statusEntries.map(([, c]) => c));
for (const [label, count] of statusEntries) {
const ratio = maxStatus > 0 ? count / maxStatus : 0;
console.log(`${label.padEnd(10)} ${bar(ratio)} ${count}`);
}
console.log("└");
// Section 3: Entity Graph
console.log("\n┌─ Entity Graph");
console.log("│");
console.log(`│ Entities: ${graphStats.entityCount}`);
console.log(`│ Mentions: ${graphStats.mentionCount}`);
console.log(`│ Density: ${graphStats.density.toFixed(2)} mentions/entity`);
console.log(`│ Orphans: ${orphanEntities.length}`);
console.log("└");
// Section 4: Tag Health
console.log("\n┌─ Tag Health");
console.log("│");
console.log(`│ Orphan tags: ${orphanTags.length}`);
console.log(`│ Single-use tags: ${singleUseTags.length}`);
console.log("└");
// Section 5: Decay Distribution
console.log("\n┌─ Decay Distribution");
console.log("│");
if (decayDist.length > 0) {
const maxDecay = Math.max(...decayDist.map((d) => d.count));
for (const { bucket, count } of decayDist) {
const ratio = maxDecay > 0 ? count / maxDecay : 0;
console.log(`${bucket.padEnd(13)} ${bar(ratio)} ${count}`);
}
} else {
console.log("│ No non-core memories found.");
}
console.log("└\n");
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
},
{ commands: [] }, // Adds subcommands to existing "memory" command, no conflict
);
}

View File

@@ -0,0 +1,728 @@
/**
* Tests for config.ts — Configuration Parsing.
*
* Tests memoryNeo4jConfigSchema.parse(), vectorDimsForModel(), and resolveExtractionConfig().
*/
import { describe, it, expect, afterEach } from "vitest";
import {
memoryNeo4jConfigSchema,
vectorDimsForModel,
contextLengthForModel,
DEFAULT_EMBEDDING_CONTEXT_LENGTH,
resolveExtractionConfig,
} from "./config.js";
// ============================================================================
// memoryNeo4jConfigSchema.parse()
// ============================================================================
describe("memoryNeo4jConfigSchema.parse", () => {
// Store original env vars so we can restore them
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
describe("valid complete configs", () => {
it("should parse a minimal valid config with ollama provider", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
});
expect(config.neo4j.uri).toBe("bolt://localhost:7687");
expect(config.neo4j.username).toBe("neo4j");
expect(config.neo4j.password).toBe("test");
expect(config.embedding.provider).toBe("ollama");
expect(config.embedding.model).toBe("mxbai-embed-large");
expect(config.embedding.apiKey).toBeUndefined();
expect(config.autoCapture).toBe(true);
expect(config.autoRecall).toBe(true);
expect(config.coreMemory.enabled).toBe(true);
});
it("should parse a full config with openai provider", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: {
uri: "neo4j+s://cloud.neo4j.io:7687",
username: "admin",
password: "secret",
},
embedding: {
provider: "openai",
apiKey: "sk-test-key",
model: "text-embedding-3-large",
},
autoCapture: false,
autoRecall: false,
coreMemory: {
enabled: false,
refreshAtContextPercent: 75,
},
});
expect(config.neo4j.uri).toBe("neo4j+s://cloud.neo4j.io:7687");
expect(config.neo4j.username).toBe("admin");
expect(config.neo4j.password).toBe("secret");
expect(config.embedding.provider).toBe("openai");
expect(config.embedding.apiKey).toBe("sk-test-key");
expect(config.embedding.model).toBe("text-embedding-3-large");
expect(config.autoCapture).toBe(false);
expect(config.autoRecall).toBe(false);
expect(config.coreMemory.enabled).toBe(false);
expect(config.coreMemory.refreshAtContextPercent).toBe(75);
});
it("should support 'user' field as alias for 'username' in neo4j config", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "custom-user", password: "pass" },
embedding: { provider: "ollama" },
});
expect(config.neo4j.username).toBe("custom-user");
});
it("should support 'username' field in neo4j config", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", username: "custom-user", password: "pass" },
embedding: { provider: "ollama" },
});
expect(config.neo4j.username).toBe("custom-user");
});
it("should default neo4j username to 'neo4j' when not specified", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "pass" },
embedding: { provider: "ollama" },
});
expect(config.neo4j.username).toBe("neo4j");
});
});
describe("missing required fields", () => {
it("should throw when config is null", () => {
expect(() => memoryNeo4jConfigSchema.parse(null)).toThrow("memory-neo4j config required");
});
it("should throw when config is undefined", () => {
expect(() => memoryNeo4jConfigSchema.parse(undefined)).toThrow(
"memory-neo4j config required",
);
});
it("should throw when config is not an object", () => {
expect(() => memoryNeo4jConfigSchema.parse("string")).toThrow("memory-neo4j config required");
});
it("should throw when config is an array", () => {
expect(() => memoryNeo4jConfigSchema.parse([])).toThrow("memory-neo4j config required");
});
it("should throw when neo4j section is missing", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
embedding: { provider: "ollama" },
}),
).toThrow("neo4j config section is required");
});
it("should throw when neo4j.uri is missing", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { password: "test" },
embedding: { provider: "ollama" },
}),
).toThrow("neo4j.uri is required");
});
it("should throw when neo4j.uri is empty string", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "", password: "test" },
embedding: { provider: "ollama" },
}),
).toThrow("neo4j.uri is required");
});
});
describe("environment variable resolution", () => {
it("should resolve ${ENV_VAR} in neo4j.password", () => {
process.env.TEST_NEO4J_PASSWORD = "resolved-password";
const config = memoryNeo4jConfigSchema.parse({
neo4j: {
uri: "bolt://localhost:7687",
password: "${TEST_NEO4J_PASSWORD}",
},
embedding: { provider: "ollama" },
});
expect(config.neo4j.password).toBe("resolved-password");
});
it("should resolve ${ENV_VAR} in embedding.apiKey", () => {
process.env.TEST_OPENAI_KEY = "sk-from-env";
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "openai", apiKey: "${TEST_OPENAI_KEY}" },
});
expect(config.embedding.apiKey).toBe("sk-from-env");
});
it("should resolve ${ENV_VAR} in neo4j.user (username)", () => {
process.env.TEST_NEO4J_USER = "resolved-user";
const config = memoryNeo4jConfigSchema.parse({
neo4j: {
uri: "bolt://localhost:7687",
user: "${TEST_NEO4J_USER}",
password: "",
},
embedding: { provider: "ollama" },
});
expect(config.neo4j.username).toBe("resolved-user");
});
it("should resolve ${ENV_VAR} in neo4j.username", () => {
process.env.TEST_NEO4J_USERNAME = "resolved-username";
const config = memoryNeo4jConfigSchema.parse({
neo4j: {
uri: "bolt://localhost:7687",
username: "${TEST_NEO4J_USERNAME}",
password: "",
},
embedding: { provider: "ollama" },
});
expect(config.neo4j.username).toBe("resolved-username");
});
it("should throw when referenced env var is not set", () => {
delete process.env.NONEXISTENT_VAR;
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: {
uri: "bolt://localhost:7687",
password: "${NONEXISTENT_VAR}",
},
embedding: { provider: "ollama" },
}),
).toThrow("Environment variable NONEXISTENT_VAR is not set");
});
});
describe("default values", () => {
it("should default autoCapture to true", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.autoCapture).toBe(true);
});
it("should default autoRecall to true", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.autoRecall).toBe(true);
});
it("should default coreMemory.enabled to true", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.coreMemory.enabled).toBe(true);
});
it("should default refreshAtContextPercent to undefined", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
it("should default embedding model to mxbai-embed-large for ollama", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.embedding.model).toBe("mxbai-embed-large");
});
it("should default embedding model to text-embedding-3-small for openai", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "openai", apiKey: "sk-test" },
});
expect(config.embedding.model).toBe("text-embedding-3-small");
});
it("should default neo4j.password to empty string when not provided", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687" },
embedding: { provider: "ollama" },
});
expect(config.neo4j.password).toBe("");
});
});
describe("provider validation", () => {
it("should require apiKey for openai provider", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "openai" },
}),
).toThrow("embedding.apiKey is required for OpenAI provider");
});
it("should not require apiKey for ollama provider", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.embedding.apiKey).toBeUndefined();
});
it("should default to openai when no provider is specified", () => {
// No provider but has apiKey — should default to openai
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { apiKey: "sk-test" },
});
expect(config.embedding.provider).toBe("openai");
});
it("should accept embedding.baseUrl", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama", baseUrl: "http://my-ollama:11434" },
});
expect(config.embedding.baseUrl).toBe("http://my-ollama:11434");
});
});
describe("unknown keys rejected", () => {
it("should reject unknown top-level keys", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
unknownKey: "value",
}),
).toThrow("unknown keys: unknownKey");
});
it("should reject unknown neo4j keys", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "", port: 7687 },
embedding: { provider: "ollama" },
}),
).toThrow("unknown keys: port");
});
it("should reject unknown embedding keys", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama", temperature: 0.5 },
}),
).toThrow("unknown keys: temperature");
});
it("should reject unknown coreMemory keys", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { unknownField: true },
}),
).toThrow("unknown keys: unknownField");
});
});
describe("refreshAtContextPercent edge cases", () => {
it("should accept refreshAtContextPercent of 1 (minimum valid)", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 1 },
});
expect(config.coreMemory.refreshAtContextPercent).toBe(1);
});
it("should accept refreshAtContextPercent of 100 (maximum valid)", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 100 },
});
expect(config.coreMemory.refreshAtContextPercent).toBe(100);
});
it("should reject refreshAtContextPercent of 0", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 0 },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
it("should reject refreshAtContextPercent over 100 by throwing", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 150 },
}),
).toThrow("coreMemory.refreshAtContextPercent must be between 1 and 100");
});
it("should reject negative refreshAtContextPercent", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: -10 },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
it("should ignore non-number refreshAtContextPercent", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: "50" },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
});
describe("autoRecallMinScore", () => {
it("should default autoRecallMinScore to 0.25 when not specified", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.autoRecallMinScore).toBe(0.25);
});
it("should accept an explicit autoRecallMinScore value", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
autoRecallMinScore: 0.5,
});
expect(config.autoRecallMinScore).toBe(0.5);
});
it("should accept autoRecallMinScore of 0", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
autoRecallMinScore: 0,
});
expect(config.autoRecallMinScore).toBe(0);
});
it("should accept autoRecallMinScore of 1", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
autoRecallMinScore: 1,
});
expect(config.autoRecallMinScore).toBe(1);
});
it("should throw when autoRecallMinScore is negative", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
autoRecallMinScore: -0.1,
}),
).toThrow("autoRecallMinScore must be between 0 and 1");
});
it("should throw when autoRecallMinScore is greater than 1", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
autoRecallMinScore: 1.5,
}),
).toThrow("autoRecallMinScore must be between 0 and 1");
});
it("should default to 0.25 when autoRecallMinScore is a non-number type", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
autoRecallMinScore: "0.5",
});
expect(config.autoRecallMinScore).toBe(0.25);
});
});
describe("sleepCycle config section", () => {
it("should default sleepCycle.auto to true", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
});
expect(config.sleepCycle.auto).toBe(true);
});
it("should respect explicit sleepCycle.auto = false", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
sleepCycle: { auto: false },
});
expect(config.sleepCycle.auto).toBe(false);
});
it("should still accept autoIntervalMs without error (backwards compat)", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
sleepCycle: { autoIntervalMs: 3600000 },
});
expect(config.sleepCycle.auto).toBe(true);
});
it("should reject unknown sleepCycle keys", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
sleepCycle: { unknownKey: true },
}),
).toThrow("unknown keys: unknownKey");
});
});
describe("extraction config section", () => {
it("should parse extraction config when provided", () => {
process.env.EXTRACTION_DUMMY = ""; // avoid env var issues
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
extraction: {
apiKey: "or-test-key",
model: "google/gemini-2.0-flash-001",
baseUrl: "https://openrouter.ai/api/v1",
},
});
expect(config.extraction).toBeDefined();
expect(config.extraction!.apiKey).toBe("or-test-key");
expect(config.extraction!.model).toBe("google/gemini-2.0-flash-001");
});
it("should not include extraction when section is empty", () => {
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
extraction: {},
});
expect(config.extraction).toBeUndefined();
});
it("should reject unknown keys in extraction section", () => {
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", password: "" },
embedding: { provider: "ollama" },
extraction: { badKey: "value" },
}),
).toThrow("unknown keys: badKey");
});
});
});
// ============================================================================
// vectorDimsForModel()
// ============================================================================
describe("vectorDimsForModel", () => {
describe("known models", () => {
it("should return 1536 for text-embedding-3-small", () => {
expect(vectorDimsForModel("text-embedding-3-small")).toBe(1536);
});
it("should return 3072 for text-embedding-3-large", () => {
expect(vectorDimsForModel("text-embedding-3-large")).toBe(3072);
});
it("should return 1024 for mxbai-embed-large", () => {
expect(vectorDimsForModel("mxbai-embed-large")).toBe(1024);
});
it("should return 768 for nomic-embed-text", () => {
expect(vectorDimsForModel("nomic-embed-text")).toBe(768);
});
it("should return 384 for all-minilm", () => {
expect(vectorDimsForModel("all-minilm")).toBe(384);
});
});
describe("prefix matching", () => {
it("should match versioned model names via prefix", () => {
// mxbai-embed-large:latest should match mxbai-embed-large
expect(vectorDimsForModel("mxbai-embed-large:latest")).toBe(1024);
});
it("should match model with additional version suffix", () => {
expect(vectorDimsForModel("nomic-embed-text:v1.5")).toBe(768);
});
});
describe("unknown models", () => {
it("should return default 1024 for unknown model", () => {
expect(vectorDimsForModel("unknown-model")).toBe(1024);
});
it("should return default 1024 for empty string", () => {
expect(vectorDimsForModel("")).toBe(1024);
});
it("should return default 1024 for unrecognized prefix", () => {
expect(vectorDimsForModel("custom-embed-v2")).toBe(1024);
});
});
});
// ============================================================================
// resolveExtractionConfig()
// ============================================================================
describe("resolveExtractionConfig", () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it("should return disabled config when no API key or explicit baseUrl", () => {
delete process.env.OPENROUTER_API_KEY;
const config = resolveExtractionConfig();
expect(config.enabled).toBe(false);
expect(config.apiKey).toBe("");
});
it("should enable when OPENROUTER_API_KEY env var is set", () => {
process.env.OPENROUTER_API_KEY = "or-env-key";
const config = resolveExtractionConfig();
expect(config.enabled).toBe(true);
expect(config.apiKey).toBe("or-env-key");
});
it("should enable when plugin config provides apiKey", () => {
delete process.env.OPENROUTER_API_KEY;
const config = resolveExtractionConfig({
apiKey: "or-plugin-key",
model: "custom-model",
baseUrl: "https://custom.ai/api",
});
expect(config.enabled).toBe(true);
expect(config.apiKey).toBe("or-plugin-key");
expect(config.model).toBe("custom-model");
expect(config.baseUrl).toBe("https://custom.ai/api");
});
it("should enable when baseUrl is explicitly set (local Ollama, no API key)", () => {
delete process.env.OPENROUTER_API_KEY;
const config = resolveExtractionConfig({
model: "llama3",
baseUrl: "http://localhost:11434/v1",
});
expect(config.enabled).toBe(true);
expect(config.apiKey).toBe("");
expect(config.baseUrl).toBe("http://localhost:11434/v1");
});
it("should use defaults for model and baseUrl", () => {
delete process.env.OPENROUTER_API_KEY;
delete process.env.EXTRACTION_MODEL;
delete process.env.EXTRACTION_BASE_URL;
const config = resolveExtractionConfig();
expect(config.model).toBe("anthropic/claude-opus-4-6");
expect(config.baseUrl).toBe("https://openrouter.ai/api/v1");
});
it("should use EXTRACTION_MODEL env var", () => {
delete process.env.OPENROUTER_API_KEY;
process.env.EXTRACTION_MODEL = "meta/llama-3-70b";
const config = resolveExtractionConfig();
expect(config.model).toBe("meta/llama-3-70b");
});
it("should use EXTRACTION_BASE_URL env var", () => {
delete process.env.OPENROUTER_API_KEY;
process.env.EXTRACTION_BASE_URL = "https://my-proxy.ai/v1";
const config = resolveExtractionConfig();
expect(config.baseUrl).toBe("https://my-proxy.ai/v1");
});
it("should always set temperature to 0.0 and maxRetries to 2", () => {
const config = resolveExtractionConfig();
expect(config.temperature).toBe(0.0);
expect(config.maxRetries).toBe(2);
});
});
// ============================================================================
// contextLengthForModel()
// ============================================================================
describe("contextLengthForModel", () => {
describe("exact match", () => {
it("should return 512 for mxbai-embed-large", () => {
expect(contextLengthForModel("mxbai-embed-large")).toBe(512);
});
it("should return 8191 for text-embedding-3-small (OpenAI)", () => {
expect(contextLengthForModel("text-embedding-3-small")).toBe(8191);
});
it("should return 8191 for text-embedding-3-large (OpenAI)", () => {
expect(contextLengthForModel("text-embedding-3-large")).toBe(8191);
});
it("should return 8192 for nomic-embed-text", () => {
expect(contextLengthForModel("nomic-embed-text")).toBe(8192);
});
it("should return 256 for all-minilm", () => {
expect(contextLengthForModel("all-minilm")).toBe(256);
});
});
describe("prefix match", () => {
it("should match mxbai-embed-large-8k:latest via prefix to 8192", () => {
expect(contextLengthForModel("mxbai-embed-large-8k:latest")).toBe(8192);
});
it("should match nomic-embed-text:v1.5 via prefix to 8192", () => {
expect(contextLengthForModel("nomic-embed-text:v1.5")).toBe(8192);
});
});
describe("unknown model fallback", () => {
it("should return DEFAULT_EMBEDDING_CONTEXT_LENGTH for unknown model", () => {
expect(contextLengthForModel("some-unknown-model")).toBe(DEFAULT_EMBEDDING_CONTEXT_LENGTH);
});
it("should return 512 as the default context length", () => {
// Verify the default value itself is 512
expect(DEFAULT_EMBEDDING_CONTEXT_LENGTH).toBe(512);
expect(contextLengthForModel("some-unknown-model")).toBe(512);
});
it("should return default for empty string", () => {
expect(contextLengthForModel("")).toBe(DEFAULT_EMBEDDING_CONTEXT_LENGTH);
});
});
});

View File

@@ -0,0 +1,397 @@
/**
* Configuration schema for memory-neo4j plugin.
*
* Matches the JSON Schema in openclaw.plugin.json.
* Provides runtime parsing with env var resolution and defaults.
*/
import type { MemoryCategory } from "./schema.js";
import { MEMORY_CATEGORIES } from "./schema.js";
export type { MemoryCategory };
export { MEMORY_CATEGORIES };
export type EmbeddingProvider = "openai" | "ollama";
export type MemoryNeo4jConfig = {
neo4j: {
uri: string;
username: string;
password: string;
};
embedding: {
provider: EmbeddingProvider;
apiKey?: string;
model: string;
baseUrl?: string;
};
extraction?: {
apiKey?: string;
model: string;
baseUrl: string;
};
autoCapture: boolean;
autoCaptureSkipPattern?: RegExp;
autoRecall: boolean;
autoRecallMinScore: number;
/**
* RegExp pattern to skip auto-recall for matching session keys.
* Useful for voice/realtime sessions where latency is critical.
* Example: /voice|realtime/ skips sessions containing "voice" or "realtime".
*/
autoRecallSkipPattern?: RegExp;
coreMemory: {
enabled: boolean;
/**
* Re-inject core memories when context usage reaches this percentage (0-100).
* Helps counter "lost in the middle" phenomenon by refreshing core memories
* closer to the end of context for recency bias.
* Set to null/undefined to disable (default).
*/
refreshAtContextPercent?: number;
};
/**
* Maximum relationship hops for graph search spreading activation.
* Default: 1 (direct + 1-hop neighbors).
* Setting to 2+ enables deeper traversal but may slow queries.
*/
graphSearchDepth: number;
/**
* Per-category decay curve parameters. Each category can have its own
* half-life (days) controlling how fast memories in that category decay.
* Categories not listed use the sleep cycle's default (30 days).
*/
decayCurves: Record<string, { halfLifeDays: number }>;
sleepCycle: {
auto: boolean;
};
};
/**
* Extraction configuration resolved from environment variables.
* Entity extraction auto-enables when OPENROUTER_API_KEY is set.
*/
export type ExtractionConfig = {
enabled: boolean;
apiKey: string;
model: string;
baseUrl: string;
temperature: number;
maxRetries: number;
};
export const EMBEDDING_DIMENSIONS: Record<string, number> = {
// OpenAI models
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
// Ollama models (common ones)
"mxbai-embed-large": 1024,
"mxbai-embed-large-2k:latest": 1024,
"nomic-embed-text": 768,
"all-minilm": 384,
};
// Default dimension for unknown models (Ollama models vary)
export const DEFAULT_EMBEDDING_DIMS = 1024;
/**
* Lookup a value by exact key or longest matching prefix.
* Returns undefined if no match found.
*/
function lookupByPrefix<T>(table: Record<string, T>, key: string): T | undefined {
if (table[key] !== undefined) {
return table[key];
}
let best: { value: T; keyLen: number } | undefined;
for (const [known, value] of Object.entries(table)) {
if (key.startsWith(known) && (!best || known.length > best.keyLen)) {
best = { value, keyLen: known.length };
}
}
return best?.value;
}
export function vectorDimsForModel(model: string): number {
// Return default for unknown models — callers should warn when this path is taken,
// as the default 1024 dimensions may not match the actual model's output.
return lookupByPrefix(EMBEDDING_DIMENSIONS, model) ?? DEFAULT_EMBEDDING_DIMS;
}
/** Max input token lengths for known embedding models. */
export const EMBEDDING_CONTEXT_LENGTHS: Record<string, number> = {
// OpenAI models
"text-embedding-3-small": 8191,
"text-embedding-3-large": 8191,
// Ollama models
"mxbai-embed-large": 512,
"mxbai-embed-large-2k": 2048,
"mxbai-embed-large-8k": 8192,
"nomic-embed-text": 8192,
"all-minilm": 256,
};
/** Conservative default for unknown models. */
export const DEFAULT_EMBEDDING_CONTEXT_LENGTH = 512;
export function contextLengthForModel(model: string): number {
return lookupByPrefix(EMBEDDING_CONTEXT_LENGTHS, model) ?? DEFAULT_EMBEDDING_CONTEXT_LENGTH;
}
/**
* Resolve ${ENV_VAR} references in string values.
*/
function resolveEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = process.env[envVar];
if (!envValue) {
throw new Error(`Environment variable ${envVar} is not set`);
}
return envValue;
});
}
/**
* Resolve extraction config from plugin config with env var fallback.
* Enabled when an API key is available (cloud) or a baseUrl is explicitly
* configured (Ollama / local LLMs that don't need a key).
*/
export function resolveExtractionConfig(
cfgExtraction?: MemoryNeo4jConfig["extraction"],
): ExtractionConfig {
const apiKey = cfgExtraction?.apiKey ?? process.env.OPENROUTER_API_KEY ?? "";
const model = cfgExtraction?.model ?? process.env.EXTRACTION_MODEL ?? "anthropic/claude-opus-4-6";
const baseUrl =
cfgExtraction?.baseUrl ?? process.env.EXTRACTION_BASE_URL ?? "https://openrouter.ai/api/v1";
// Enabled when an API key is set (cloud provider) or baseUrl was explicitly
// configured in the plugin config (Ollama / local — no key needed).
const enabled = apiKey.length > 0 || cfgExtraction?.baseUrl != null;
return {
enabled,
apiKey,
model,
baseUrl,
temperature: 0.0,
maxRetries: 2,
};
}
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length > 0) {
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
}
/** Parse autoRecallMinScore: must be a number between 0 and 1, default 0.25. */
function parseAutoRecallMinScore(value: unknown): number {
if (typeof value !== "number") return 0.25;
if (value < 0 || value > 1) {
throw new Error(`autoRecallMinScore must be between 0 and 1, got: ${value}`);
}
return value;
}
/**
* Config schema with parse method for runtime validation & transformation.
* JSON Schema validation is handled by openclaw.plugin.json; this handles
* env var resolution and defaults.
*/
export const memoryNeo4jConfigSchema = {
parse(value: unknown): MemoryNeo4jConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("memory-neo4j config required");
}
const cfg = value as Record<string, unknown>;
assertAllowedKeys(
cfg,
[
"embedding",
"neo4j",
"autoCapture",
"autoCaptureSkipPattern",
"autoRecall",
"autoRecallMinScore",
"autoRecallSkipPattern",
"coreMemory",
"extraction",
"graphSearchDepth",
"decayCurves",
"sleepCycle",
],
"memory-neo4j config",
);
// Parse neo4j section
const neo4jRaw = cfg.neo4j as Record<string, unknown> | undefined;
if (!neo4jRaw || typeof neo4jRaw !== "object") {
throw new Error("neo4j config section is required");
}
assertAllowedKeys(neo4jRaw, ["uri", "user", "username", "password"], "neo4j config");
if (typeof neo4jRaw.uri !== "string" || !neo4jRaw.uri) {
throw new Error("neo4j.uri is required");
}
const neo4jUri = resolveEnvVars(neo4jRaw.uri);
// Validate URI scheme — must be a valid Neo4j connection protocol
const VALID_NEO4J_SCHEMES = [
"bolt://",
"bolt+s://",
"bolt+ssc://",
"neo4j://",
"neo4j+s://",
"neo4j+ssc://",
];
if (!VALID_NEO4J_SCHEMES.some((scheme) => neo4jUri.startsWith(scheme))) {
throw new Error(
`neo4j.uri must start with a valid scheme (${VALID_NEO4J_SCHEMES.join(", ")}), got: "${neo4jUri}"`,
);
}
const neo4jPassword =
typeof neo4jRaw.password === "string" ? resolveEnvVars(neo4jRaw.password) : "";
// Support both 'user' and 'username' for neo4j config
const neo4jUsername =
typeof neo4jRaw.user === "string"
? resolveEnvVars(neo4jRaw.user)
: typeof neo4jRaw.username === "string"
? resolveEnvVars(neo4jRaw.username)
: "neo4j";
// Parse embedding section (optional for ollama without apiKey)
const embeddingRaw = cfg.embedding as Record<string, unknown> | undefined;
assertAllowedKeys(
embeddingRaw ?? {},
["provider", "apiKey", "model", "baseUrl"],
"embedding config",
);
const provider: EmbeddingProvider = embeddingRaw?.provider === "ollama" ? "ollama" : "openai";
// apiKey is required for openai, optional for ollama
let apiKey: string | undefined;
if (typeof embeddingRaw?.apiKey === "string" && embeddingRaw.apiKey) {
apiKey = resolveEnvVars(embeddingRaw.apiKey);
} else if (provider === "openai") {
throw new Error("embedding.apiKey is required for OpenAI provider");
}
const embeddingModel =
typeof embeddingRaw?.model === "string"
? embeddingRaw.model
: provider === "ollama"
? "mxbai-embed-large"
: "text-embedding-3-small";
const baseUrl = typeof embeddingRaw?.baseUrl === "string" ? embeddingRaw.baseUrl : undefined;
// Parse coreMemory section (optional with defaults)
const coreMemoryRaw = cfg.coreMemory as Record<string, unknown> | undefined;
assertAllowedKeys(
coreMemoryRaw ?? {},
["enabled", "refreshAtContextPercent"],
"coreMemory config",
);
const coreMemoryEnabled = coreMemoryRaw?.enabled !== false; // enabled by default
// refreshAtContextPercent: number between 1-99 to be effective, or undefined to disable.
// Values at 0 or below are ignored (disables refresh). Values above 100 are invalid.
if (
typeof coreMemoryRaw?.refreshAtContextPercent === "number" &&
coreMemoryRaw.refreshAtContextPercent > 100
) {
throw new Error(
`coreMemory.refreshAtContextPercent must be between 1 and 100, got: ${coreMemoryRaw.refreshAtContextPercent}`,
);
}
const refreshAtContextPercent =
typeof coreMemoryRaw?.refreshAtContextPercent === "number" &&
coreMemoryRaw.refreshAtContextPercent > 0 &&
coreMemoryRaw.refreshAtContextPercent <= 100
? coreMemoryRaw.refreshAtContextPercent
: undefined;
// Parse extraction section (optional — falls back to env vars in resolveExtractionConfig)
const extractionRaw = cfg.extraction as Record<string, unknown> | undefined;
assertAllowedKeys(extractionRaw ?? {}, ["apiKey", "model", "baseUrl"], "extraction config");
let extraction: MemoryNeo4jConfig["extraction"];
if (extractionRaw) {
const exApiKey =
typeof extractionRaw.apiKey === "string" ? resolveEnvVars(extractionRaw.apiKey) : undefined;
const exModel = typeof extractionRaw.model === "string" ? extractionRaw.model : undefined;
const exBaseUrl =
typeof extractionRaw.baseUrl === "string" ? extractionRaw.baseUrl : undefined;
// Only include if at least one field was provided
if (exApiKey || exModel || exBaseUrl) {
extraction = {
apiKey: exApiKey,
model: exModel ?? (process.env.EXTRACTION_MODEL || "anthropic/claude-opus-4-6"),
baseUrl: exBaseUrl ?? (process.env.EXTRACTION_BASE_URL || "https://openrouter.ai/api/v1"),
};
}
}
// Parse decayCurves: per-category decay curve overrides
const decayCurvesRaw = cfg.decayCurves as Record<string, unknown> | undefined;
const decayCurves: Record<string, { halfLifeDays: number }> = {};
if (decayCurvesRaw && typeof decayCurvesRaw === "object") {
for (const [cat, val] of Object.entries(decayCurvesRaw)) {
if (val && typeof val === "object" && "halfLifeDays" in val) {
const hl = (val as Record<string, unknown>).halfLifeDays;
if (typeof hl === "number" && hl > 0) {
decayCurves[cat] = { halfLifeDays: hl };
} else {
throw new Error(`decayCurves.${cat}.halfLifeDays must be a positive number`);
}
}
}
}
// Parse graphSearchDepth: must be 1-3, default 1
const rawDepth = cfg.graphSearchDepth;
let graphSearchDepth = 1;
if (typeof rawDepth === "number") {
if (rawDepth < 1 || rawDepth > 3 || !Number.isInteger(rawDepth)) {
throw new Error(`graphSearchDepth must be 1, 2, or 3, got: ${rawDepth}`);
}
graphSearchDepth = rawDepth;
}
// Parse sleepCycle section (optional with defaults)
const sleepCycleRaw = cfg.sleepCycle as Record<string, unknown> | undefined;
assertAllowedKeys(sleepCycleRaw ?? {}, ["auto", "autoIntervalMs"], "sleepCycle config");
const sleepCycleAuto = sleepCycleRaw?.auto !== false; // enabled by default
return {
neo4j: {
uri: neo4jUri,
username: neo4jUsername,
password: neo4jPassword,
},
embedding: {
provider,
apiKey,
model: embeddingModel,
baseUrl,
},
extraction,
autoCapture: cfg.autoCapture !== false,
autoCaptureSkipPattern:
typeof cfg.autoCaptureSkipPattern === "string" && cfg.autoCaptureSkipPattern
? new RegExp(cfg.autoCaptureSkipPattern)
: undefined,
autoRecall: cfg.autoRecall !== false,
autoRecallMinScore: parseAutoRecallMinScore(cfg.autoRecallMinScore),
autoRecallSkipPattern:
typeof cfg.autoRecallSkipPattern === "string" && cfg.autoRecallSkipPattern
? new RegExp(cfg.autoRecallSkipPattern)
: undefined,
coreMemory: {
enabled: coreMemoryEnabled,
refreshAtContextPercent,
},
graphSearchDepth,
decayCurves,
sleepCycle: {
auto: sleepCycleAuto,
},
};
},
};

View File

@@ -0,0 +1,481 @@
/**
* Tests for embeddings.ts — Embedding Provider.
*
* Tests the Embeddings class with mocked OpenAI client and mocked fetch for Ollama.
*/
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
// ============================================================================
// Constructor
// ============================================================================
describe("Embeddings constructor", () => {
it("should throw when OpenAI provider is used without API key", async () => {
const { Embeddings } = await import("./embeddings.js");
expect(() => new Embeddings(undefined, "text-embedding-3-small", "openai")).toThrow(
"API key required for OpenAI embeddings",
);
});
it("should not require API key for ollama provider", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
expect(emb).toBeDefined();
});
});
// ============================================================================
// Ollama embed
// ============================================================================
describe("Embeddings - Ollama provider", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("should call Ollama API with correct request body", async () => {
const { Embeddings } = await import("./embeddings.js");
const mockVector = [0.1, 0.2, 0.3, 0.4];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ embeddings: [mockVector] }),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
const result = await emb.embed("test text");
expect(result).toEqual(mockVector);
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:11434/api/embed",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "mxbai-embed-large",
input: "test text",
}),
}),
);
});
it("should use custom baseUrl for Ollama", async () => {
const { Embeddings } = await import("./embeddings.js");
const mockVector = [0.5, 0.6];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ embeddings: [mockVector] }),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama", "http://my-host:11434");
await emb.embed("test");
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://my-host:11434/api/embed",
expect.any(Object),
);
});
it("should strip trailing slashes from baseUrl", async () => {
const { Embeddings } = await import("./embeddings.js");
const mockVector = [0.1, 0.2];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ embeddings: [mockVector] }),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama", "http://my-host:11434/");
await emb.embed("test");
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://my-host:11434/api/embed",
expect.any(Object),
);
});
it("should strip multiple trailing slashes from baseUrl", async () => {
const { Embeddings } = await import("./embeddings.js");
const mockVector = [0.1, 0.2];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ embeddings: [mockVector] }),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama", "http://my-host:11434///");
await emb.embed("test");
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://my-host:11434/api/embed",
expect.any(Object),
);
});
it("should throw when Ollama returns error status", async () => {
const { Embeddings } = await import("./embeddings.js");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve("Internal Server Error"),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
await expect(emb.embed("test")).rejects.toThrow("Ollama embedding failed: 500");
});
it("should throw when Ollama returns no embeddings", async () => {
const { Embeddings } = await import("./embeddings.js");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ embeddings: [] }),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
await expect(emb.embed("test")).rejects.toThrow("No embedding returned from Ollama");
});
it("should throw when Ollama returns null embeddings", async () => {
const { Embeddings } = await import("./embeddings.js");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
await expect(emb.embed("test")).rejects.toThrow("No embedding returned from Ollama");
});
it("should propagate fetch errors for Ollama", async () => {
const { Embeddings } = await import("./embeddings.js");
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
await expect(emb.embed("test")).rejects.toThrow("Network error");
});
});
// ============================================================================
// OpenAI embed (via mocked client internals)
// ============================================================================
describe("Embeddings - OpenAI provider", () => {
it("should create instance with OpenAI provider when API key provided", async () => {
const { Embeddings } = await import("./embeddings.js");
// Just verify construction succeeds with valid params
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
expect(emb).toBeDefined();
});
it("should have embed and embedBatch methods", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
expect(typeof emb.embed).toBe("function");
expect(typeof emb.embedBatch).toBe("function");
});
});
// ============================================================================
// Batch embedding
// ============================================================================
describe("Embeddings - embedBatch", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("should return empty array for empty input (openai)", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test", "text-embedding-3-small", "openai");
const results = await emb.embedBatch([]);
expect(results).toEqual([]);
});
it("should return empty array for empty input (ollama)", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
const results = await emb.embedBatch([]);
expect(results).toEqual([]);
});
it("should use sequential calls for Ollama batch (no native batch support)", async () => {
const { Embeddings } = await import("./embeddings.js");
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ embeddings: [[callCount * 0.1, callCount * 0.2]] }),
});
});
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
const results = await emb.embedBatch(["text1", "text2", "text3"]);
// Should make 3 separate calls
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
expect(results).toHaveLength(3);
// Each result should be a vector
for (const r of results) {
expect(Array.isArray(r)).toBe(true);
expect(r.length).toBe(2);
}
});
});
// ============================================================================
// Ollama context-length truncation
// ============================================================================
describe("Embeddings - Ollama context-length truncation", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ embeddings: [[0.1, 0.2, 0.3]] }),
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("should truncate long input before calling Ollama embed", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
// mxbai-embed-large context length is 512, so maxChars = 512 * 3 = 1536
// Create input that exceeds the limit
const longText = "word ".repeat(500); // ~2500 chars, well above 1536
await emb.embed(longText);
// Verify the text sent to Ollama was truncated
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(body.input.length).toBeLessThanOrEqual(512 * 3);
});
it("should truncate at word boundary (not mid-word)", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
// maxChars for mxbai-embed-large = 512 * 3 = 1536
// Each "abcdefghij " is 11 chars; 200 repeats = 2200 chars total (exceeds 1536)
const longText = "abcdefghij ".repeat(200);
await emb.embed(longText);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body as string);
const sentText = body.input as string;
expect(sentText.length).toBeLessThanOrEqual(512 * 3);
// The truncation should land on a word boundary: the sent text should
// be a prefix of the original that ends at a complete word (i.e. the
// character after the sent text in the original should be a space).
// Since the pattern is "abcdefghij " repeated, a word-boundary cut
// means sentText ends with "abcdefghij" (no trailing partial word).
expect(sentText).toMatch(/abcdefghij$/);
// Verify it's a proper prefix of the original
expect(longText.startsWith(sentText)).toBe(true);
});
it("should pass short input through unchanged", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
const shortText = "This is a short text that fits within context length.";
await emb.embed(shortText);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body as string);
expect(body.input).toBe(shortText);
});
it("should use model-specific context length for truncation", async () => {
const { Embeddings } = await import("./embeddings.js");
// nomic-embed-text has context length 8192, maxChars = 8192 * 3 = 24576
const emb = new Embeddings(undefined, "nomic-embed-text", "ollama");
// Create text that exceeds mxbai limit (1536) but fits nomic limit (24576)
const mediumText = "hello ".repeat(400); // ~2400 chars
await emb.embed(mediumText);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body as string);
// Should NOT be truncated since 2400 < 24576
expect(body.input).toBe(mediumText);
});
it("should truncate each item individually in embedBatch", async () => {
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings(undefined, "mxbai-embed-large", "ollama");
// maxChars for mxbai-embed-large = 512 * 3 = 1536
const longText = "word ".repeat(500); // ~2500 chars, exceeds limit
const shortText = "short text"; // well under limit
await emb.embedBatch([longText, shortText]);
const calls = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls;
expect(calls).toHaveLength(2);
// First call: long text should be truncated
const body1 = JSON.parse(calls[0][1].body as string);
expect(body1.input.length).toBeLessThanOrEqual(512 * 3);
expect(body1.input.length).toBeLessThan(longText.length);
// Second call: short text should pass through unchanged
const body2 = JSON.parse(calls[1][1].body as string);
expect(body2.input).toBe(shortText);
});
});
// ============================================================================
// OpenAI embed — functional tests with mocked OpenAI client
// ============================================================================
describe("Embeddings - OpenAI functional", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("embed() should call OpenAI API with correct model and input", async () => {
const mockCreate = vi.fn().mockResolvedValue({
data: [{ index: 0, embedding: [0.1, 0.2, 0.3] }],
});
// Mock the openai module
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: mockCreate };
},
}));
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
const result = await emb.embed("hello world");
expect(result).toEqual([0.1, 0.2, 0.3]);
expect(mockCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: "hello world",
});
});
it("embedBatch() should send all texts in a single API call and return correctly ordered results", async () => {
const mockCreate = vi.fn().mockResolvedValue({
// Return out-of-order to verify sorting by index
data: [
{ index: 2, embedding: [0.7, 0.8, 0.9] },
{ index: 0, embedding: [0.1, 0.2, 0.3] },
{ index: 1, embedding: [0.4, 0.5, 0.6] },
],
});
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: mockCreate };
},
}));
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
const results = await emb.embedBatch(["first", "second", "third"]);
// Should have made exactly one API call with all texts
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: ["first", "second", "third"],
});
// Results should be sorted by index (0, 1, 2)
expect(results).toEqual([
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
[0.7, 0.8, 0.9],
]);
});
it("embed() should propagate OpenAI API errors", async () => {
const mockCreate = vi.fn().mockRejectedValue(new Error("API rate limit exceeded"));
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: mockCreate };
},
}));
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
await expect(emb.embed("test")).rejects.toThrow("API rate limit exceeded");
});
it("embed() should return cached result on second call for same text", async () => {
const mockCreate = vi.fn().mockResolvedValue({
data: [{ index: 0, embedding: [0.1, 0.2, 0.3] }],
});
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: mockCreate };
},
}));
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
const result1 = await emb.embed("cached text");
const result2 = await emb.embed("cached text");
expect(result1).toEqual([0.1, 0.2, 0.3]);
expect(result2).toEqual([0.1, 0.2, 0.3]);
// Should only make one API call — second call uses cache
expect(mockCreate).toHaveBeenCalledTimes(1);
});
it("embedBatch() should use cache for previously embedded texts", async () => {
const mockCreate = vi
.fn()
.mockResolvedValueOnce({
data: [{ index: 0, embedding: [0.1, 0.2, 0.3] }],
})
.mockResolvedValueOnce({
data: [{ index: 0, embedding: [0.7, 0.8, 0.9] }],
});
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: mockCreate };
},
}));
const { Embeddings } = await import("./embeddings.js");
const emb = new Embeddings("sk-test-key", "text-embedding-3-small", "openai");
// First: embed "alpha" to populate cache
await emb.embed("alpha");
expect(mockCreate).toHaveBeenCalledTimes(1);
// Now batch with "alpha" (cached) and "beta" (uncached)
const results = await emb.embedBatch(["alpha", "beta"]);
// Should only call API once more for "beta"
expect(mockCreate).toHaveBeenCalledTimes(2);
expect(mockCreate).toHaveBeenLastCalledWith({
model: "text-embedding-3-small",
input: ["beta"],
});
expect(results).toEqual([
[0.1, 0.2, 0.3], // cached
[0.7, 0.8, 0.9], // freshly computed
]);
});
});

View File

@@ -0,0 +1,322 @@
/**
* Embedding generation for memory-neo4j.
*
* Supports both OpenAI and Ollama providers.
* Includes an LRU cache to avoid redundant API calls within a session.
*/
import { createHash } from "node:crypto";
import OpenAI from "openai";
import type { EmbeddingProvider } from "./config.js";
import type { Logger } from "./schema.js";
import { contextLengthForModel } from "./config.js";
/**
* Simple LRU cache for embedding vectors.
* Keyed by SHA-256 hash of the input text to avoid storing large strings.
*/
class EmbeddingCache {
private readonly map = new Map<string, number[]>();
private readonly maxSize: number;
constructor(maxSize: number = 200) {
this.maxSize = maxSize;
}
private static hashText(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
get(text: string): number[] | undefined {
const key = EmbeddingCache.hashText(text);
const value = this.map.get(key);
if (value !== undefined) {
// Move to end (most recently used) by re-inserting
this.map.delete(key);
this.map.set(key, value);
}
return value;
}
set(text: string, embedding: number[]): void {
const key = EmbeddingCache.hashText(text);
// If key exists, delete first to refresh position
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.maxSize) {
// Evict oldest (first) entry
const oldest = this.map.keys().next().value;
if (oldest !== undefined) {
this.map.delete(oldest);
}
}
this.map.set(key, embedding);
}
get size(): number {
return this.map.size;
}
}
/** Default concurrency for Ollama embedding requests */
const OLLAMA_EMBED_CONCURRENCY = 4;
export class Embeddings {
private client: OpenAI | null = null;
private readonly provider: EmbeddingProvider;
private readonly baseUrl: string;
private readonly logger: Logger | undefined;
private readonly contextLength: number;
private readonly cache = new EmbeddingCache(200);
constructor(
private readonly apiKey: string | undefined,
private readonly model: string = "text-embedding-3-small",
provider: EmbeddingProvider = "openai",
baseUrl?: string,
logger?: Logger,
) {
this.provider = provider;
this.baseUrl = (baseUrl ?? (provider === "ollama" ? "http://localhost:11434" : "")).replace(
/\/+$/,
"",
);
this.logger = logger;
this.contextLength = contextLengthForModel(model);
if (provider === "openai") {
if (!apiKey) {
throw new Error("API key required for OpenAI embeddings");
}
this.client = new OpenAI({ apiKey });
}
}
/**
* Truncate text to fit within the model's context length.
* Uses a conservative ~3 chars/token estimate to leave headroom —
* code, URLs, and punctuation-heavy text tokenize at 12 chars/token,
* so the classic ~4 estimate is too generous for mixed content.
* Truncates at a word boundary when possible.
*/
private truncateToContext(text: string): string {
const maxChars = this.contextLength * 3;
if (text.length <= maxChars) {
return text;
}
// Try to truncate at a word boundary
let truncated = text.slice(0, maxChars);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > maxChars * 0.8) {
truncated = truncated.slice(0, lastSpace);
}
this.logger?.debug?.(
`memory-neo4j: truncated embedding input from ${text.length} to ${truncated.length} chars (model context: ${this.contextLength} tokens)`,
);
return truncated;
}
/**
* Generate an embedding vector for a single text.
* Results are cached to avoid redundant API calls.
*/
async embed(text: string): Promise<number[]> {
const input = this.truncateToContext(text);
// Check cache first
const cached = this.cache.get(input);
if (cached) {
this.logger?.debug?.("memory-neo4j: embedding cache hit");
return cached;
}
const embedding =
this.provider === "ollama" ? await this.embedOllama(input) : await this.embedOpenAI(input);
this.cache.set(input, embedding);
return embedding;
}
/**
* Generate embeddings for multiple texts.
* Returns array of embeddings in the same order as input.
*
* For Ollama: processes in chunks of OLLAMA_EMBED_CONCURRENCY to avoid
* overwhelming the local server. Individual failures don't break the
* entire batch — failed embeddings are replaced with empty arrays.
*/
async embedBatch(texts: string[]): Promise<number[][]> {
if (texts.length === 0) {
return [];
}
const truncated = texts.map((t) => this.truncateToContext(t));
// Check cache for each text; only compute uncached ones
const results: (number[] | null)[] = truncated.map((t) => this.cache.get(t) ?? null);
const uncachedIndices: number[] = [];
const uncachedTexts: string[] = [];
for (let i = 0; i < results.length; i++) {
if (results[i] === null) {
uncachedIndices.push(i);
uncachedTexts.push(truncated[i]);
}
}
if (uncachedTexts.length === 0) {
this.logger?.debug?.(`memory-neo4j: embedBatch fully cached (${texts.length} texts)`);
return results as number[][];
}
let computed: number[][];
if (this.provider === "ollama") {
computed = await this.embedBatchOllama(uncachedTexts);
} else {
computed = await this.embedBatchOpenAI(uncachedTexts);
}
// Merge computed results back and populate cache
for (let i = 0; i < uncachedIndices.length; i++) {
const embedding = computed[i];
results[uncachedIndices[i]] = embedding;
if (embedding.length > 0) {
this.cache.set(uncachedTexts[i], embedding);
}
}
return results as number[][];
}
/**
* Ollama batch embedding with concurrency limiting.
* Processes in chunks to avoid overwhelming the server.
*/
private async embedBatchOllama(texts: string[]): Promise<number[][]> {
const embeddings: number[][] = [];
let failures = 0;
// Process in chunks of OLLAMA_EMBED_CONCURRENCY
for (let i = 0; i < texts.length; i += OLLAMA_EMBED_CONCURRENCY) {
const chunk = texts.slice(i, i + OLLAMA_EMBED_CONCURRENCY);
const chunkResults = await Promise.allSettled(chunk.map((t) => this.embedOllama(t)));
for (let j = 0; j < chunkResults.length; j++) {
const result = chunkResults[j];
if (result.status === "fulfilled") {
embeddings.push(result.value);
} else {
failures++;
this.logger?.warn?.(
`memory-neo4j: Ollama embedding failed for text ${i + j}: ${String(result.reason)}`,
);
// Use empty array as placeholder so indices stay aligned
embeddings.push([]);
}
}
}
if (failures > 0) {
this.logger?.warn?.(
`memory-neo4j: ${failures}/${texts.length} Ollama embeddings failed in batch`,
);
}
return embeddings;
}
private async embedOpenAI(text: string): Promise<number[]> {
if (!this.client) {
throw new Error("OpenAI client not initialized");
}
const response = await this.client.embeddings.create({
model: this.model,
input: text,
});
return response.data[0].embedding;
}
private async embedBatchOpenAI(texts: string[]): Promise<number[][]> {
if (!this.client) {
throw new Error("OpenAI client not initialized");
}
const response = await this.client.embeddings.create({
model: this.model,
input: texts,
});
// Sort by index to ensure correct order
return [...response.data].sort((a, b) => a.index - b.index).map((d) => d.embedding);
}
// Timeout for Ollama embedding fetch calls to prevent hanging indefinitely
private static readonly EMBED_TIMEOUT_MS = 30_000;
// Retry configuration for transient Ollama errors (model loading, GPU pressure)
private static readonly OLLAMA_MAX_RETRIES = 2;
private static readonly OLLAMA_RETRY_BASE_DELAY_MS = 1000;
private async embedOllama(text: string): Promise<number[]> {
let lastError: unknown;
for (let attempt = 0; attempt <= Embeddings.OLLAMA_MAX_RETRIES; attempt++) {
try {
return await this.fetchOllamaEmbedding(text);
} catch (err) {
lastError = err;
if (attempt < Embeddings.OLLAMA_MAX_RETRIES) {
const delay = Embeddings.OLLAMA_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
this.logger?.warn?.(
`memory-neo4j: Ollama embedding failed (attempt ${attempt + 1}/${Embeddings.OLLAMA_MAX_RETRIES + 1}), retrying in ${delay}ms: ${String(err)}`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
private async fetchOllamaEmbedding(text: string): Promise<number[]> {
const url = `${this.baseUrl}/api/embed`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: this.model,
input: text,
}),
signal: AbortSignal.timeout(Embeddings.EMBED_TIMEOUT_MS),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Ollama embedding failed: ${response.status} ${error}`);
}
const data = (await response.json()) as { embeddings?: number[][] };
if (!data.embeddings?.[0]) {
throw new Error("No embedding returned from Ollama");
}
return data.embeddings[0];
}
}
/**
* Compute cosine similarity between two embedding vectors.
* Returns a value between -1 and 1 (1 = identical, 0 = orthogonal).
* Returns 0 if either vector is empty or they differ in length.
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || a.length !== b.length) {
return 0;
}
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,715 @@
/**
* LLM-based entity extraction and memory operations for memory-neo4j.
*
* Extraction uses a configurable OpenAI-compatible LLM (OpenRouter, Ollama, etc.) to:
* - Extract entities, relationships, and tags from stored memories
* - Classify memories into categories (preference, fact, decision, etc.)
* - Rate memory importance on a 1-10 scale
* - Detect semantic duplicates via LLM comparison
* - Resolve conflicting memories
*
* Runs as background fire-and-forget operations with graceful degradation.
*/
import { randomUUID } from "node:crypto";
import type { ExtractionConfig } from "./config.js";
import type { Embeddings } from "./embeddings.js";
import type { Neo4jMemoryClient } from "./neo4j-client.js";
import type { EntityType, ExtractionResult, Logger, MemoryCategory } from "./schema.js";
import { callOpenRouter, callOpenRouterStream, isTransientError } from "./llm-client.js";
import { ALLOWED_RELATIONSHIP_TYPES, ENTITY_TYPES, MEMORY_CATEGORIES } from "./schema.js";
// ============================================================================
// Extraction Prompt
// ============================================================================
// System instruction (no user data) — user message contains the memory text
const ENTITY_EXTRACTION_SYSTEM = `You are an entity extraction system for a personal memory store.
Extract entities and relationships from the memory text provided by the user, and classify the memory.
Return JSON:
{
"category": "preference|fact|decision|entity|other",
"entities": [
{"name": "alice", "type": "person", "aliases": ["manager"], "description": "brief description"}
],
"relationships": [
{"source": "alice", "target": "acme corp", "type": "WORKS_AT", "confidence": 0.95}
],
"tags": [
{"name": "neo4j", "category": "technology"}
]
}
Rules:
- Normalize entity names to lowercase
- Entity types: person, organization, location, event, concept
- Relationship types: WORKS_AT, LIVES_AT, KNOWS, MARRIED_TO, PREFERS, DECIDED, RELATED_TO
- Confidence: 0.0-1.0
- Only extract SPECIFIC named entities: real people, companies, products, tools, places, events
- Do NOT extract generic technology terms (python, javascript, docker, linux, api, sql, html, css, json, etc.)
- Do NOT extract generic concepts (meeting, project, training, email, code, data, server, file, script, etc.)
- Do NOT extract programming abstractions (function, class, module, async, sync, process, etc.)
- Good entities: "Tarun", "Abundent Academy", "Tioman Island", "LiveKit", "Neo4j", "Fish Speech S1 Mini"
- Bad entities: "python", "ai", "automation", "email", "docker", "machine learning", "api"
- When in doubt, do NOT extract — fewer high-quality entities beat many generic ones
- Keep entity descriptions brief (1 sentence max)
- Category: "preference" for opinions/preferences, "fact" for factual info, "decision" for choices made, "entity" for entity-focused, "other" for miscellaneous
- ALWAYS generate at least 2 tags. Every memory has a topic — there are no exceptions.
- Tags describe the TOPIC or DOMAIN of the memory, not the entities themselves.
- Do NOT use entity names as tags (e.g., don't tag "tarun" if Tarun is already an entity).
- Good tags: "travel planning", "family", "voice synthesis", "linkedin automation", "expense tracking", "cron scheduling", "api integration"
- Tag categories: "topic", "domain", "workflow", "technology", "personal", "business"
- Return empty entity/relationship arrays if nothing specific to extract, but NEVER return empty tags.`;
// ============================================================================
// Retroactive Tagging Prompt
// ============================================================================
/**
* Lightweight prompt for retroactive tagging of memories that were extracted
* without tags. Only asks for tags — no entities or relationships.
*/
const RETROACTIVE_TAGGING_SYSTEM = `You are a topic tagging system for a personal memory store.
Generate 2-4 topic tags that describe what this memory is about.
Return JSON:
{
"tags": [
{"name": "tag name", "category": "topic|domain|workflow|technology|personal|business"}
]
}
Rules:
- Tags describe the TOPIC or DOMAIN of the memory, not specific people or tools mentioned.
- Good tags: "travel planning", "family", "voice synthesis", "linkedin automation", "expense tracking", "cron scheduling", "api integration", "system configuration", "memory management"
- Bad tags: names of people, companies, or specific tools (those are entities, not topics)
- Tag categories: "topic" (general subject), "domain" (field/area), "workflow" (process/procedure), "technology" (tech area), "personal" (personal life), "business" (work/business)
- ALWAYS return at least 2 tags. Every memory has a topic.
- Normalize tag names to lowercase with spaces (no hyphens or underscores).`;
// ============================================================================
// Entity Extraction
// ============================================================================
/**
* Max retries for transient extraction failures before marking permanently failed.
*
* Retry budget accounting — two layers of retry:
* Layer 1: callOpenRouter/callOpenRouterStream internal retries (config.maxRetries, default 2 = 3 attempts)
* Layer 2: Sleep cycle retries (MAX_EXTRACTION_RETRIES = 3 sleep cycles)
* Total worst-case: 3 × 3 = 9 LLM attempts per memory
*/
const MAX_EXTRACTION_RETRIES = 3;
/**
* Extract entities and relationships from a memory text using LLM.
*
* Uses streaming for responsive abort signal handling and better latency.
*
* Returns { result, transientFailure }:
* - result is the ExtractionResult or null if extraction returned nothing useful
* - transientFailure is true if the failure was due to a network/timeout issue
* (caller should retry later) vs a permanent failure (bad JSON, etc.)
*/
export async function extractEntities(
text: string,
config: ExtractionConfig,
abortSignal?: AbortSignal,
): Promise<{ result: ExtractionResult | null; transientFailure: boolean }> {
if (!config.enabled) {
return { result: null, transientFailure: false };
}
// System/user separation prevents memory text from being interpreted as instructions
const messages = [
{ role: "system", content: ENTITY_EXTRACTION_SYSTEM },
{ role: "user", content: text },
];
let content: string | null;
try {
// Use streaming for extraction — allows responsive abort and better latency
content = await callOpenRouterStream(config, messages, abortSignal);
} catch (err) {
// Network/timeout errors are transient — caller should retry
return { result: null, transientFailure: isTransientError(err) };
}
if (!content) {
return { result: null, transientFailure: false };
}
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
return { result: validateExtractionResult(parsed), transientFailure: false };
} catch {
// JSON parse failure is permanent — LLM returned malformed output
return { result: null, transientFailure: false };
}
}
/**
* Extract only tags from a memory text using a lightweight LLM prompt.
* Used for retroactive tagging of memories that were extracted without tags.
*
* Returns an array of tags, or null on failure.
*/
export async function extractTagsOnly(
text: string,
config: ExtractionConfig,
abortSignal?: AbortSignal,
): Promise<Array<{ name: string; category: string }> | null> {
if (!config.enabled) {
return null;
}
const messages = [
{ role: "system", content: RETROACTIVE_TAGGING_SYSTEM },
{ role: "user", content: text },
];
let content: string | null;
try {
content = await callOpenRouterStream(config, messages, abortSignal);
} catch {
return null;
}
if (!content) {
return null;
}
try {
const parsed = JSON.parse(content) as { tags?: unknown };
const rawTags = Array.isArray(parsed.tags) ? parsed.tags : [];
return rawTags
.filter(
(t: unknown): t is Record<string, unknown> =>
t !== null &&
typeof t === "object" &&
typeof (t as Record<string, unknown>).name === "string",
)
.map((t) => ({
name: normalizeTagName(String(t.name)),
category: typeof t.category === "string" ? t.category : "topic",
}))
.filter((t) => t.name.length > 0);
} catch {
return null;
}
}
/**
* Normalize a tag name: lowercase, collapse hyphens/underscores to spaces,
* collapse multiple spaces, trim. Ensures "machine-learning", "machine_learning",
* and "machine learning" all resolve to the same tag node.
*/
function normalizeTagName(name: string): string {
return name.trim().toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
}
/**
* Generic terms that should never be extracted as entities.
* These are common technology/concept words that the LLM tends to
* extract despite prompt instructions. Post-filter is more reliable
* than prompt engineering alone.
*/
const GENERIC_ENTITY_BLOCKLIST = new Set([
// Programming languages & frameworks
"python",
"javascript",
"typescript",
"java",
"go",
"rust",
"ruby",
"php",
"c",
"c++",
"c#",
"swift",
"kotlin",
"bash",
"shell",
"html",
"css",
"sql",
"nosql",
"json",
"xml",
"yaml",
"react",
"vue",
"angular",
"svelte",
"next.js",
"express",
"fastapi",
"django",
"flask",
// Generic tech concepts
"ai",
"artificial intelligence",
"machine learning",
"deep learning",
"neural network",
"automation",
"api",
"rest api",
"graphql",
"webhook",
"websocket",
"database",
"server",
"client",
"cloud",
"microservice",
"monolith",
"frontend",
"backend",
"fullstack",
"devops",
"ci/cd",
"deployment",
// Generic tools/infra
"docker",
"kubernetes",
"linux",
"windows",
"macos",
"nginx",
"apache",
"git",
"npm",
"pnpm",
"yarn",
"pip",
"node",
"nodejs",
"node.js",
// Generic work concepts
"meeting",
"project",
"training",
"email",
"calendar",
"task",
"ticket",
"code",
"data",
"file",
"folder",
"directory",
"script",
"module",
"debug",
"deploy",
"build",
"release",
"update",
"upgrade",
"user",
"admin",
"system",
"service",
"process",
"job",
"worker",
// Programming abstractions
"function",
"class",
"method",
"variable",
"object",
"array",
"string",
"async",
"sync",
"promise",
"callback",
"event",
"hook",
"middleware",
"component",
"plugin",
"extension",
"library",
"package",
"dependency",
// Generic descriptors
"app",
"application",
"web",
"mobile",
"desktop",
"browser",
"config",
"configuration",
"settings",
"environment",
"production",
"staging",
"error",
"bug",
"issue",
"fix",
"patch",
"feature",
"improvement",
]);
/**
* Validate and sanitize LLM extraction output.
*/
function validateExtractionResult(raw: Record<string, unknown>): ExtractionResult {
const entities = Array.isArray(raw.entities) ? raw.entities : [];
const relationships = Array.isArray(raw.relationships) ? raw.relationships : [];
const tags = Array.isArray(raw.tags) ? raw.tags : [];
const validEntityTypes = new Set<string>(ENTITY_TYPES);
const validCategories = new Set<string>(MEMORY_CATEGORIES);
const rawCategory = typeof raw.category === "string" ? raw.category : undefined;
const category =
rawCategory && validCategories.has(rawCategory) ? (rawCategory as MemoryCategory) : undefined;
return {
category,
entities: entities
.filter(
(e: unknown): e is Record<string, unknown> =>
e !== null &&
typeof e === "object" &&
typeof (e as Record<string, unknown>).name === "string" &&
typeof (e as Record<string, unknown>).type === "string",
)
.map((e) => ({
name: String(e.name).trim().toLowerCase(),
type: validEntityTypes.has(String(e.type)) ? (String(e.type) as EntityType) : "concept",
aliases: Array.isArray(e.aliases)
? (e.aliases as unknown[])
.filter((a): a is string => typeof a === "string")
.map((a) => a.trim().toLowerCase())
: undefined,
description: typeof e.description === "string" ? e.description : undefined,
}))
.filter((e) => e.name.length > 0 && !GENERIC_ENTITY_BLOCKLIST.has(e.name)),
relationships: relationships
.filter(
(r: unknown): r is Record<string, unknown> =>
r !== null &&
typeof r === "object" &&
typeof (r as Record<string, unknown>).source === "string" &&
typeof (r as Record<string, unknown>).target === "string" &&
typeof (r as Record<string, unknown>).type === "string" &&
ALLOWED_RELATIONSHIP_TYPES.has(String((r as Record<string, unknown>).type)),
)
.map((r) => ({
source: String(r.source).trim().toLowerCase(),
target: String(r.target).trim().toLowerCase(),
type: String(r.type),
confidence: typeof r.confidence === "number" ? Math.min(1, Math.max(0, r.confidence)) : 0.7,
})),
tags: tags
.filter(
(t: unknown): t is Record<string, unknown> =>
t !== null &&
typeof t === "object" &&
typeof (t as Record<string, unknown>).name === "string",
)
.map((t) => ({
name: normalizeTagName(String(t.name)),
category: typeof t.category === "string" ? t.category : "topic",
}))
.filter((t) => t.name.length > 0),
};
}
// ============================================================================
// Conflict Resolution
// ============================================================================
/**
* Use an LLM to determine whether two memories genuinely conflict.
* Returns which memory to keep, or "both" if they don't actually conflict.
* Returns "skip" on any failure (network, parse, disabled config).
*/
export async function resolveConflict(
memA: string,
memB: string,
config: ExtractionConfig,
abortSignal?: AbortSignal,
): Promise<"a" | "b" | "both" | "skip"> {
if (!config.enabled) return "skip";
try {
const content = await callOpenRouter(
config,
[
{
role: "system",
content: `Two memories may conflict with each other. Determine which should be kept.
If they genuinely contradict each other, keep the one that is more current, specific, or accurate.
If they don't actually conflict (they cover different aspects or are both valid), keep both.
Return JSON: {"keep": "a"|"b"|"both", "reason": "brief explanation"}`,
},
{ role: "user", content: `Memory A: "${memA}"\nMemory B: "${memB}"` },
],
abortSignal,
);
if (!content) return "skip";
const parsed = JSON.parse(content) as { keep?: string };
const keep = parsed.keep;
if (keep === "a" || keep === "b" || keep === "both") return keep;
return "skip";
} catch {
return "skip";
}
}
// ============================================================================
// Background Extraction Pipeline
// ============================================================================
/**
* Run entity extraction in the background for a stored memory.
* Fire-and-forget: errors are logged but never propagated.
*
* Flow:
* 1. Call LLM to extract entities and relationships
* 2. MERGE Entity nodes (idempotent)
* 3. Create MENTIONS relationships from Memory → Entity
* 4. Create inter-Entity relationships (WORKS_AT, KNOWS, etc.)
* 5. Tag the memory
* 6. Update extractionStatus to "complete", "pending" (transient retry), or "failed"
*
* Transient failures (network/timeout) leave status as "pending" with an incremented
* retry counter. After MAX_EXTRACTION_RETRIES transient failures, the memory is
* permanently marked "failed". Permanent failures (malformed JSON) are immediately "failed".
*/
export async function runBackgroundExtraction(
memoryId: string,
text: string,
db: Neo4jMemoryClient,
embeddings: Embeddings,
config: ExtractionConfig,
logger: Logger,
currentRetries: number = 0,
abortSignal?: AbortSignal,
): Promise<{ success: boolean; memoryId: string }> {
if (!config.enabled) {
await db.updateExtractionStatus(memoryId, "skipped").catch(() => {});
return { success: true, memoryId };
}
try {
const { result, transientFailure } = await extractEntities(text, config, abortSignal);
if (!result) {
if (transientFailure) {
// Transient failure (network/timeout) — leave as pending for retry
const retries = currentRetries + 1;
if (retries >= MAX_EXTRACTION_RETRIES) {
logger.warn(
`memory-neo4j: extraction permanently failed for ${memoryId.slice(0, 8)} after ${retries} transient retries`,
);
await db.updateExtractionStatus(memoryId, "failed", { incrementRetries: true });
} else {
logger.info(
`memory-neo4j: extraction transient failure for ${memoryId.slice(0, 8)}, will retry (${retries}/${MAX_EXTRACTION_RETRIES})`,
);
// Keep status as "pending" but increment retry counter
await db.updateExtractionStatus(memoryId, "pending", { incrementRetries: true });
}
} else {
// Permanent failure (JSON parse, empty response, etc.)
await db.updateExtractionStatus(memoryId, "failed");
}
return { success: false, memoryId };
}
// Empty extraction is valid — not all memories have extractable entities
if (
result.entities.length === 0 &&
result.relationships.length === 0 &&
result.tags.length === 0
) {
await db.updateExtractionStatus(memoryId, "complete");
return { success: true, memoryId };
}
// Batch all entity operations into a single transaction:
// entity merges, mentions, relationships, tags, category, and extraction status
await db.batchEntityOperations(
memoryId,
result.entities.map((e) => ({
id: randomUUID(),
name: e.name,
type: e.type,
aliases: e.aliases,
description: e.description,
})),
result.relationships,
result.tags,
result.category,
);
logger.info(
`memory-neo4j: extraction complete for ${memoryId.slice(0, 8)}` +
`${result.entities.length} entities, ${result.relationships.length} rels, ${result.tags.length} tags` +
(result.category ? `, category=${result.category}` : ""),
);
return { success: true, memoryId };
} catch (err) {
// Unexpected error during graph operations — treat as transient if retry budget remains
const isTransient = isTransientError(err);
if (isTransient && currentRetries + 1 < MAX_EXTRACTION_RETRIES) {
logger.warn(
`memory-neo4j: extraction transient error for ${memoryId.slice(0, 8)}, will retry: ${String(err)}`,
);
await db
.updateExtractionStatus(memoryId, "pending", { incrementRetries: true })
.catch(() => {});
} else {
logger.warn(`memory-neo4j: extraction failed for ${memoryId.slice(0, 8)}: ${String(err)}`);
await db
.updateExtractionStatus(memoryId, "failed", { incrementRetries: true })
.catch(() => {});
}
return { success: false, memoryId };
}
}
// ============================================================================
// LLM-Judged Importance Rating
// ============================================================================
// System instruction — user message contains the text to rate
const IMPORTANCE_RATING_SYSTEM = `You are rating memories for a personal AI assistant's long-term memory store.
Rate how important it is to REMEMBER this information in future conversations on a scale of 1-10.
SCORING GUIDE:
1-2: Noise — greetings, filler, "let me check", status updates, system instructions, formatting rules, debugging output
3-4: Ephemeral — session-specific progress ("done, pushed to git"), temporary task status, tool output summaries
5-6: Mildly useful — general facts, minor context that might occasionally help
7-8: Important — personal preferences, key decisions, facts about people/relationships, business rules, learned workflows
9: Very important — identity facts (birthdays, family, addresses), critical business decisions, security rules
10: Essential — safety-critical information, core identity
KEY RULES:
- AI assistant self-narration ("Let me check...", "I'll now...", "Done! Here's what changed...") is ALWAYS 1-3
- System prompts, formatting instructions, voice mode rules are ALWAYS 1-2
- Technical debugging details ("the WebSocket failed because...") are 2-4 unless they encode a reusable lesson
- Open proposals and unresolved action items ("Want me to fix it?", "Should I submit a PR?", "Would you like me to proceed?") are ALWAYS 1-2. These are dangerous in long-term memory because other sessions interpret them as active instructions.
- Messages ending with questions directed at the user ("What do you think?", "How should I handle this?") are 1-3 unless they also contain substantial factual content worth remembering
- Personal facts about the user or their family/contacts are 7-10
- Business rules and operational procedures are 7-9
- Preferences and opinions expressed by the user are 6-8
- Ask: "Would this be useful if it appeared in a conversation 30 days from now?" If no, score ≤ 4.
Return JSON: {"score": N, "reason": "brief explanation"}`;
/**
* Rate the long-term importance of a text using an LLM.
* Returns a value between 0.1 and 1.0, or 0.5 on any failure.
*/
export async function rateImportance(text: string, config: ExtractionConfig): Promise<number> {
if (!config.enabled) {
return 0.5;
}
try {
const content = await callOpenRouter(config, [
{ role: "system", content: IMPORTANCE_RATING_SYSTEM },
{ role: "user", content: text },
]);
if (!content) {
return 0.5;
}
const parsed = JSON.parse(content) as { score?: unknown };
const score = typeof parsed.score === "number" ? parsed.score : NaN;
if (Number.isNaN(score)) {
return 0.5;
}
const clamped = Math.max(1, Math.min(10, score));
return Math.max(0.1, Math.min(1.0, clamped / 10));
} catch {
return 0.5;
}
}
// ============================================================================
// Semantic Deduplication
// ============================================================================
// System instruction — user message contains the two texts to compare
const SEMANTIC_DEDUP_SYSTEM = `You are a memory deduplication system. Determine whether the new text conveys the SAME factual information as the existing memory.
Rules:
- Return "duplicate" if the new text is conveying the same core fact(s), even if worded differently
- Return "duplicate" if the new text is a subset of information already in the existing memory
- Return "unique" if the new text contains genuinely new information not in the existing memory
- Ignore differences in formatting, pronouns, or phrasing — focus on the underlying facts
Return JSON: {"verdict": "duplicate"|"unique", "reason": "brief explanation"}`;
/**
* Minimum cosine similarity to proceed with the LLM comparison.
* Below this threshold, texts are too dissimilar to be semantic duplicates,
* saving an expensive LLM call. Exported for testing.
*/
export const SEMANTIC_DEDUP_VECTOR_THRESHOLD = 0.8;
/**
* Check whether new text is semantically a duplicate of an existing memory.
*
* When a pre-computed vector similarity score is provided (from findSimilar
* or findDuplicateClusters), the LLM call is skipped entirely for pairs
* below SEMANTIC_DEDUP_VECTOR_THRESHOLD — a fast pre-screen that avoids
* the most expensive part of the pipeline.
*
* Returns true if the new text is a duplicate (should be skipped).
* Returns false on any failure (allow storage).
*/
export async function isSemanticDuplicate(
newText: string,
existingText: string,
config: ExtractionConfig,
vectorSimilarity?: number,
abortSignal?: AbortSignal,
): Promise<boolean> {
if (!config.enabled) {
return false;
}
// Vector pre-screen: skip LLM call when similarity is below threshold
if (vectorSimilarity !== undefined && vectorSimilarity < SEMANTIC_DEDUP_VECTOR_THRESHOLD) {
return false;
}
try {
const content = await callOpenRouter(
config,
[
{ role: "system", content: SEMANTIC_DEDUP_SYSTEM },
{ role: "user", content: `Existing memory: "${existingText}"\nNew text: "${newText}"` },
],
abortSignal,
);
if (!content) {
return false;
}
const parsed = JSON.parse(content) as { verdict?: string };
return parsed.verdict === "duplicate";
} catch {
return false;
}
}

View File

@@ -0,0 +1,754 @@
/**
* Tests for the memory-neo4j plugin entry point.
*
* Covers:
* 1. Attention gates (user and assistant) — re-exported from attention-gate.ts
* 2. Message extraction — extractUserMessages, extractAssistantMessages from message-utils.ts
* 3. Strip wrappers — stripMessageWrappers, stripAssistantWrappers from message-utils.ts
*
* Does NOT test the plugin registration or CLI commands (those require the
* full OpenClaw SDK runtime). Focuses on pure functions and the behavioral
* contracts of the auto-capture pipeline helpers.
*/
import { describe, it, expect } from "vitest";
import { passesAttentionGate, passesAssistantAttentionGate } from "./attention-gate.js";
import {
extractUserMessages,
extractAssistantMessages,
stripMessageWrappers,
stripAssistantWrappers,
} from "./message-utils.js";
// ============================================================================
// Test Helpers
// ============================================================================
/** Generate a string of a specific length using a repeating word pattern. */
function makeText(wordCount: number, word = "lorem"): string {
return Array.from({ length: wordCount }, () => word).join(" ");
}
/** Generate a string of a specific character length. */
function makeChars(charCount: number, char = "x"): string {
return char.repeat(charCount);
}
// ============================================================================
// passesAttentionGate() — User Attention Gate
// ============================================================================
describe("passesAttentionGate", () => {
// -----------------------------------------------------------------------
// Length bounds
// -----------------------------------------------------------------------
describe("length bounds", () => {
it("should reject messages shorter than 30 characters", () => {
expect(passesAttentionGate("too short")).toBe(false);
expect(passesAttentionGate("a".repeat(29))).toBe(false);
});
it("should reject messages longer than 2000 characters", () => {
// 2001 chars — exceeds MAX_CAPTURE_CHARS
const longText = makeText(300, "longword");
expect(longText.length).toBeGreaterThan(2000);
expect(passesAttentionGate(longText)).toBe(false);
});
it("should accept messages at exactly 30 characters with sufficient words", () => {
// Need 30+ chars and 8+ words
const text = "ab cd ef gh ij kl mn op qr st u";
expect(text.length).toBeGreaterThanOrEqual(30);
expect(text.split(/\s+/).length).toBeGreaterThanOrEqual(8);
expect(passesAttentionGate(text)).toBe(true);
});
it("should accept messages at exactly 2000 characters with sufficient words", () => {
// Build exactly 2000 chars: repeated "testing " (8 chars each) = 250 words
// 250 * 8 = 2000, but join adds spaces between (not after last), so 250 * 7 + 249 = 1999
// Use a padded approach: fill with "testing " then pad to exactly 2000
const base = "testing ".repeat(249) + "testing"; // 249*8 + 7 = 1999
const text = base + "s"; // 2000 chars
expect(text.length).toBe(2000);
expect(passesAttentionGate(text)).toBe(true);
});
});
// -----------------------------------------------------------------------
// Word count
// -----------------------------------------------------------------------
describe("word count", () => {
it("should reject messages with fewer than 8 words", () => {
// 7 words, but long enough in chars (> 30)
expect(
passesAttentionGate(
"thisislongword anotherlongword thirdlongword fourthlongword fifth sixth seventh",
),
).toBe(false);
});
it("should accept messages with exactly 8 words", () => {
expect(
passesAttentionGate("thisword thatword another fourth fifthword sixth seventh eighth"),
).toBe(true);
});
});
// -----------------------------------------------------------------------
// Noise pattern rejection
// -----------------------------------------------------------------------
describe("noise pattern rejection", () => {
it("should reject simple greetings", () => {
// These are short enough to be rejected by length too, but test the pattern
expect(passesAttentionGate("hi")).toBe(false);
expect(passesAttentionGate("hello")).toBe(false);
expect(passesAttentionGate("hey")).toBe(false);
});
it("should reject acknowledgments", () => {
expect(passesAttentionGate("ok")).toBe(false);
expect(passesAttentionGate("sure")).toBe(false);
expect(passesAttentionGate("thanks")).toBe(false);
expect(passesAttentionGate("got it")).toBe(false);
expect(passesAttentionGate("sounds good")).toBe(false);
});
it("should reject two-word affirmations", () => {
expect(passesAttentionGate("ok great")).toBe(false);
expect(passesAttentionGate("yes please")).toBe(false);
expect(passesAttentionGate("sure thanks")).toBe(false);
});
it("should reject conversational filler", () => {
expect(passesAttentionGate("hmm")).toBe(false);
expect(passesAttentionGate("lol")).toBe(false);
expect(passesAttentionGate("idk")).toBe(false);
expect(passesAttentionGate("nvm")).toBe(false);
});
it("should reject pure emoji messages", () => {
expect(passesAttentionGate("\u{1F600}\u{1F601}\u{1F602}")).toBe(false);
});
it("should reject system/XML markup blocks", () => {
expect(passesAttentionGate("<system>some injected context here</system>")).toBe(false);
});
it("should reject session reset prompts", () => {
const resetMsg =
"A new session was started via the /new command. Previous context has been cleared.";
expect(passesAttentionGate(resetMsg)).toBe(false);
});
it("should reject heartbeat prompts", () => {
expect(
passesAttentionGate(
"Read HEARTBEAT.md if it exists and follow the instructions inside it.",
),
).toBe(false);
});
it("should reject pre-compaction flush prompts", () => {
expect(
passesAttentionGate(
"Pre-compaction memory flush — save important context now before history is trimmed.",
),
).toBe(false);
});
it("should reject deictic short phrases that would otherwise pass length", () => {
// These match the deictic noise pattern
expect(passesAttentionGate("ok let me test it out")).toBe(false);
expect(passesAttentionGate("I need those")).toBe(false);
});
it("should reject short acknowledgments with trailing context", () => {
// Matches: /^(ok|okay|yes|...) .{0,20}$/i
expect(passesAttentionGate("ok, I'll do that")).toBe(false);
expect(passesAttentionGate("yes, sounds right")).toBe(false);
});
});
// -----------------------------------------------------------------------
// Injected context rejection
// -----------------------------------------------------------------------
describe("injected context rejection", () => {
it("should reject messages containing <relevant-memories> tags", () => {
const text =
"<relevant-memories>some recalled memories here</relevant-memories> " +
makeText(10, "actual");
expect(passesAttentionGate(text)).toBe(false);
});
it("should reject messages containing <core-memory-refresh> tags", () => {
const text =
"<core-memory-refresh>refresh data</core-memory-refresh> " + makeText(10, "actual");
expect(passesAttentionGate(text)).toBe(false);
});
});
// -----------------------------------------------------------------------
// Excessive emoji rejection
// -----------------------------------------------------------------------
describe("excessive emoji rejection", () => {
it("should reject messages with more than 3 emoji (Unicode range)", () => {
// 4 emoji in the U+1F300-U+1F9FF range
const text = makeText(10, "word") + " \u{1F600}\u{1F601}\u{1F602}\u{1F603}";
expect(passesAttentionGate(text)).toBe(false);
});
it("should accept messages with 3 or fewer emoji", () => {
const text = makeText(10, "testing") + " \u{1F600}\u{1F601}\u{1F602}";
expect(passesAttentionGate(text)).toBe(true);
});
});
// -----------------------------------------------------------------------
// Substantive messages that should pass
// -----------------------------------------------------------------------
describe("substantive messages", () => {
it("should accept a clear factual statement", () => {
expect(passesAttentionGate("I prefer dark mode for all my code editors and terminals")).toBe(
true,
);
});
it("should accept a preference statement", () => {
expect(
passesAttentionGate(
"My favorite programming language is TypeScript because of its type system",
),
).toBe(true);
});
it("should accept a decision statement", () => {
expect(
passesAttentionGate(
"We decided to use Neo4j for the knowledge graph instead of PostgreSQL",
),
).toBe(true);
});
it("should accept a multi-sentence message", () => {
expect(
passesAttentionGate(
"The deployment pipeline uses GitHub Actions. It builds and tests on every push to main.",
),
).toBe(true);
});
it("should handle leading/trailing whitespace via trimming", () => {
expect(
passesAttentionGate(" I prefer using vitest for testing my TypeScript projects "),
).toBe(true);
});
});
});
// ============================================================================
// passesAssistantAttentionGate() — Assistant Attention Gate
// ============================================================================
describe("passesAssistantAttentionGate", () => {
// -----------------------------------------------------------------------
// Length bounds (stricter than user)
// -----------------------------------------------------------------------
describe("length bounds", () => {
it("should reject messages shorter than 30 characters", () => {
expect(passesAssistantAttentionGate("short msg")).toBe(false);
});
it("should reject messages longer than 1000 characters", () => {
const longText = makeText(200, "wordword");
expect(longText.length).toBeGreaterThan(1000);
expect(passesAssistantAttentionGate(longText)).toBe(false);
});
});
// -----------------------------------------------------------------------
// Word count (higher threshold — 10 words minimum)
// -----------------------------------------------------------------------
describe("word count", () => {
it("should reject messages with fewer than 10 words", () => {
// 9 words, each 5 chars + space = more than 30 chars total
const nineWords = "alpha bravo charm delta eerie found ghost horse india";
expect(nineWords.split(/\s+/).length).toBe(9);
expect(nineWords.length).toBeGreaterThan(30);
expect(passesAssistantAttentionGate(nineWords)).toBe(false);
});
it("should accept messages with exactly 10 words", () => {
const tenWords = "alpha bravo charm delta eerie found ghost horse india julep";
expect(tenWords.split(/\s+/).length).toBe(10);
expect(tenWords.length).toBeGreaterThan(30);
expect(passesAssistantAttentionGate(tenWords)).toBe(true);
});
});
// -----------------------------------------------------------------------
// Code-heavy message rejection (> 50% fenced code)
// -----------------------------------------------------------------------
describe("code-heavy rejection", () => {
it("should reject messages that are more than 50% fenced code blocks", () => {
// ~60 chars of prose + ~200 chars of code block => code > 50%
const text =
"Here is some explanation for the code below that follows.\n" +
"```typescript\n" +
"function example() {\n" +
" const x = 1;\n" +
" const y = 2;\n" +
" return x + y;\n" +
"}\n" +
"function another() {\n" +
" const a = 3;\n" +
" return a * 2;\n" +
"}\n" +
"```";
expect(passesAssistantAttentionGate(text)).toBe(false);
});
it("should accept messages with less than 50% code", () => {
const text =
"The configuration requires setting up the environment variables correctly. " +
"You need to set NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD. " +
"Make sure the password is at least 8 characters long for security. " +
"```\nNEO4J_URI=bolt://localhost:7687\n```";
expect(passesAssistantAttentionGate(text)).toBe(true);
});
});
// -----------------------------------------------------------------------
// Tool output rejection
// -----------------------------------------------------------------------
describe("tool output rejection", () => {
it("should reject messages containing <tool_result> tags", () => {
const text =
"Here is the result of the search query across all the relevant documents " +
"<tool_result>some result data here</tool_result>";
expect(passesAssistantAttentionGate(text)).toBe(false);
});
it("should reject messages containing <tool_use> tags", () => {
const text =
"I will use this tool to help answer your question about the system setup " +
"<tool_use>tool invocation here</tool_use>";
expect(passesAssistantAttentionGate(text)).toBe(false);
});
it("should reject messages containing <function_call> tags", () => {
const text =
"Calling the function to retrieve the relevant data from the database now " +
"<function_call>fn call here</function_call>";
expect(passesAssistantAttentionGate(text)).toBe(false);
});
});
// -----------------------------------------------------------------------
// Injected context rejection
// -----------------------------------------------------------------------
describe("injected context rejection", () => {
it("should reject messages with <relevant-memories> tags", () => {
const text =
"<relevant-memories>cached recall data</relevant-memories> " + makeText(15, "answer");
expect(passesAssistantAttentionGate(text)).toBe(false);
});
it("should reject messages with <core-memory-refresh> tags", () => {
const text =
"<core-memory-refresh>identity refresh</core-memory-refresh> " + makeText(15, "answer");
expect(passesAssistantAttentionGate(text)).toBe(false);
});
});
// -----------------------------------------------------------------------
// Noise patterns and emoji (shared with user gate)
// -----------------------------------------------------------------------
describe("noise patterns", () => {
it("should reject greeting noise", () => {
expect(passesAssistantAttentionGate("hello")).toBe(false);
});
it("should reject excessive emoji", () => {
const text = makeText(15, "answer") + " \u{1F600}\u{1F601}\u{1F602}\u{1F603}";
expect(passesAssistantAttentionGate(text)).toBe(false);
});
});
// -----------------------------------------------------------------------
// Substantive assistant messages that should pass
// -----------------------------------------------------------------------
describe("substantive assistant messages", () => {
it("should accept a clear explanatory response", () => {
expect(
passesAssistantAttentionGate(
"The Neo4j database uses a property graph model where nodes represent entities and edges represent relationships between them.",
),
).toBe(true);
});
it("should accept a recommendation response", () => {
expect(
passesAssistantAttentionGate(
"Based on your requirements, I recommend using vitest for unit testing because it has native TypeScript support and fast execution times.",
),
).toBe(true);
});
});
});
// ============================================================================
// extractUserMessages()
// ============================================================================
describe("extractUserMessages", () => {
it("should extract text from string content format", () => {
const messages = [{ role: "user", content: "This is a substantive user message for testing" }];
const result = extractUserMessages(messages);
expect(result).toEqual(["This is a substantive user message for testing"]);
});
it("should extract text from content block array format", () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "This is a substantive user message from a block array" }],
},
];
const result = extractUserMessages(messages);
expect(result).toEqual(["This is a substantive user message from a block array"]);
});
it("should extract multiple text blocks from a single message", () => {
const messages = [
{
role: "user",
content: [
{ type: "text", text: "First text block with enough characters" },
{ type: "image", url: "http://example.com/img.png" },
{ type: "text", text: "Second text block with enough characters" },
],
},
];
const result = extractUserMessages(messages);
expect(result).toHaveLength(2);
expect(result[0]).toBe("First text block with enough characters");
expect(result[1]).toBe("Second text block with enough characters");
});
it("should ignore non-user messages", () => {
const messages = [
{ role: "assistant", content: "I am the assistant response message here" },
{ role: "system", content: "This is the system prompt configuration text" },
{ role: "user", content: "This is the actual user message text here" },
];
const result = extractUserMessages(messages);
expect(result).toEqual(["This is the actual user message text here"]);
});
it("should filter out messages shorter than 10 characters after stripping", () => {
const messages = [
{ role: "user", content: "short" },
{ role: "user", content: "This is a long enough message to pass the filter" },
];
const result = extractUserMessages(messages);
expect(result).toHaveLength(1);
expect(result[0]).toBe("This is a long enough message to pass the filter");
});
it("should strip Telegram wrappers before returning", () => {
const messages = [
{
role: "user",
content:
"[Telegram @user123 in group] The actual user message is right here\n[message_id: 456]",
},
];
const result = extractUserMessages(messages);
expect(result).toEqual(["The actual user message is right here"]);
});
it("should strip Slack wrappers before returning", () => {
const messages = [
{
role: "user",
content:
"[Slack workspace #channel @user] The actual user message text goes here\n[slack message id: abc123]",
},
];
const result = extractUserMessages(messages);
expect(result).toEqual(["The actual user message text goes here"]);
});
it("should strip injected <relevant-memories> context", () => {
const messages = [
{
role: "user",
content:
"<relevant-memories>recalled: user likes dark mode</relevant-memories> What editor do you recommend for me?",
},
];
const result = extractUserMessages(messages);
expect(result).toEqual(["What editor do you recommend for me?"]);
});
it("should handle null and non-object entries gracefully", () => {
const messages = [
null,
undefined,
42,
"string",
{ role: "user", content: "This is a valid message with enough text" },
];
const result = extractUserMessages(messages as unknown[]);
expect(result).toEqual(["This is a valid message with enough text"]);
});
it("should handle empty messages array", () => {
expect(extractUserMessages([])).toEqual([]);
});
it("should ignore content blocks that are not type 'text'", () => {
const messages = [
{
role: "user",
content: [
{ type: "image", url: "http://example.com/photo.jpg" },
{ type: "audio", data: "base64data..." },
],
},
];
const result = extractUserMessages(messages);
expect(result).toEqual([]);
});
});
// ============================================================================
// extractAssistantMessages()
// ============================================================================
describe("extractAssistantMessages", () => {
it("should extract text from string content format", () => {
const messages = [
{ role: "assistant", content: "Here is a substantive assistant response text" },
];
const result = extractAssistantMessages(messages);
expect(result).toEqual(["Here is a substantive assistant response text"]);
});
it("should extract text from content block array format", () => {
const messages = [
{
role: "assistant",
content: [{ type: "text", text: "The assistant provides an answer to your question here" }],
},
];
const result = extractAssistantMessages(messages);
expect(result).toEqual(["The assistant provides an answer to your question here"]);
});
it("should ignore non-assistant messages", () => {
const messages = [
{ role: "user", content: "This is a user message that should be ignored" },
{ role: "assistant", content: "This is the assistant response message here" },
];
const result = extractAssistantMessages(messages);
expect(result).toEqual(["This is the assistant response message here"]);
});
it("should filter out messages shorter than 10 characters after stripping", () => {
const messages = [
{ role: "assistant", content: "short" },
{ role: "assistant", content: "This is a long enough assistant response message" },
];
const result = extractAssistantMessages(messages);
expect(result).toHaveLength(1);
expect(result[0]).toBe("This is a long enough assistant response message");
});
it("should strip tool-use blocks from assistant messages", () => {
const messages = [
{
role: "assistant",
content:
"<tool_use>search function call parameters</tool_use>Here is the answer to your question about configuration",
},
];
const result = extractAssistantMessages(messages);
expect(result).toEqual(["Here is the answer to your question about configuration"]);
});
it("should strip tool_result blocks from assistant messages", () => {
const messages = [
{
role: "assistant",
content:
"The query returned: <tool_result>raw database output here</tool_result> which means the config is correct and working.",
},
];
const result = extractAssistantMessages(messages);
expect(result).toEqual(["The query returned: which means the config is correct and working."]);
});
it("should strip thinking blocks from assistant messages", () => {
const messages = [
{
role: "assistant",
content:
"<thinking>I need to figure out the best approach here</thinking>The best approach is to use a hybrid search combining vector and BM25 signals.",
},
];
const result = extractAssistantMessages(messages);
expect(result).toEqual([
"The best approach is to use a hybrid search combining vector and BM25 signals.",
]);
});
it("should strip code_output blocks from assistant messages", () => {
const messages = [
{
role: "assistant",
content:
"I ran the code: <code_output>stdout: success</code_output> and it completed without any errors at all.",
},
];
const result = extractAssistantMessages(messages);
expect(result).toEqual(["I ran the code: and it completed without any errors at all."]);
});
it("should handle null and non-object entries gracefully", () => {
const messages = [
null,
undefined,
{ role: "assistant", content: "This is a valid assistant response text" },
];
const result = extractAssistantMessages(messages as unknown[]);
expect(result).toEqual(["This is a valid assistant response text"]);
});
it("should handle empty messages array", () => {
expect(extractAssistantMessages([])).toEqual([]);
});
});
// ============================================================================
// stripMessageWrappers()
// ============================================================================
describe("stripMessageWrappers", () => {
it("should strip <relevant-memories> tags and content", () => {
const input =
"<relevant-memories>user likes dark mode</relevant-memories> What editor should I use?";
expect(stripMessageWrappers(input)).toBe("What editor should I use?");
});
it("should strip <core-memory-refresh> tags and content", () => {
const input =
"<core-memory-refresh>identity: Tarun</core-memory-refresh> How do I configure this?";
expect(stripMessageWrappers(input)).toBe("How do I configure this?");
});
it("should strip <system> tags and content", () => {
const input = "<system>You are a helpful assistant.</system> What is the weather?";
expect(stripMessageWrappers(input)).toBe("What is the weather?");
});
it("should strip <file> attachment tags", () => {
const input = '<file name="doc.pdf">base64content</file> Summarize this document for me.';
expect(stripMessageWrappers(input)).toBe("Summarize this document for me.");
});
it("should strip Telegram wrapper and message_id", () => {
const input = "[Telegram @john in private] Please remember my preference\n[message_id: 12345]";
expect(stripMessageWrappers(input)).toBe("Please remember my preference");
});
it("should strip Slack wrapper and slack message id", () => {
const input =
"[Slack acme-corp #general @alice] Please deploy the latest build\n[slack message id: ts-123]";
expect(stripMessageWrappers(input)).toBe("Please deploy the latest build");
});
it("should strip media attachment preamble", () => {
const input =
"[media attached: image/jpeg]\nTo send an image reply with...\n[Telegram @user in private] What is this picture?";
expect(stripMessageWrappers(input)).toBe("What is this picture?");
});
it("should strip System exec output blocks before Telegram wrapper", () => {
const input =
"System: [2024-01-01] exec completed\n[Telegram @user in private] What happened with the deploy?";
expect(stripMessageWrappers(input)).toBe("What happened with the deploy?");
});
it("should handle multiple wrappers in one message", () => {
const input =
"<relevant-memories>recalled facts</relevant-memories> <system>You are helpful.</system> [Telegram @user in group] What is up?";
const result = stripMessageWrappers(input);
expect(result).toBe("What is up?");
});
it("should return trimmed text when no wrappers are present", () => {
expect(stripMessageWrappers(" Just a plain message ")).toBe("Just a plain message");
});
});
// ============================================================================
// stripAssistantWrappers()
// ============================================================================
describe("stripAssistantWrappers", () => {
it("should strip <tool_use> blocks", () => {
const input = "<tool_use>call search</tool_use>The answer is 42.";
expect(stripAssistantWrappers(input)).toBe("The answer is 42.");
});
it("should strip <tool_result> blocks", () => {
const input = "Result: <tool_result>raw output</tool_result> processed successfully.";
// The regex consumes trailing whitespace after the closing tag
expect(stripAssistantWrappers(input)).toBe("Result: processed successfully.");
});
it("should strip <function_call> blocks", () => {
const input = "<function_call>fn(args)</function_call>Done with the operation.";
expect(stripAssistantWrappers(input)).toBe("Done with the operation.");
});
it("should strip <thinking> blocks", () => {
const input = "<thinking>Let me consider...</thinking>I recommend using vitest.";
expect(stripAssistantWrappers(input)).toBe("I recommend using vitest.");
});
it("should strip <antThinking> blocks", () => {
const input = "<antThinking>analyzing the request</antThinking>Here is the analysis.";
expect(stripAssistantWrappers(input)).toBe("Here is the analysis.");
});
it("should strip <code_output> blocks", () => {
const input = "Output: <code_output>success</code_output> everything worked.";
// The regex consumes trailing whitespace after the closing tag
expect(stripAssistantWrappers(input)).toBe("Output: everything worked.");
});
it("should strip multiple wrapper types in one message", () => {
const input =
"<thinking>hmm</thinking><tool_use>search</tool_use>The final answer is here.<tool_result>data</tool_result>";
expect(stripAssistantWrappers(input)).toBe("The final answer is here.");
});
it("should return trimmed text when no wrappers are present", () => {
expect(stripAssistantWrappers(" Plain assistant text ")).toBe("Plain assistant text");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
/**
* OpenRouter/OpenAI-compatible LLM API client for memory-neo4j.
*
* Handles non-streaming and streaming chat completion requests with
* retry logic, timeout handling, and abort signal support.
*/
import type { ExtractionConfig } from "./config.js";
// Timeout for LLM and embedding fetch calls to prevent hanging indefinitely
export const FETCH_TIMEOUT_MS = 30_000;
/**
* Build a combined abort signal from the caller's signal and a per-request timeout.
*/
function buildSignal(abortSignal?: AbortSignal): AbortSignal {
return abortSignal
? AbortSignal.any([abortSignal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
: AbortSignal.timeout(FETCH_TIMEOUT_MS);
}
/**
* Shared request/retry logic for OpenRouter API calls.
* Handles signal composition, request building, error handling, and exponential backoff.
* The `parseFn` callback processes the Response differently for streaming vs non-streaming.
*/
async function openRouterRequest(
config: ExtractionConfig,
messages: Array<{ role: string; content: string }>,
abortSignal: AbortSignal | undefined,
stream: boolean,
parseFn: (response: Response, abortSignal?: AbortSignal) => Promise<string | null>,
): Promise<string | null> {
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
const signal = buildSignal(abortSignal);
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
messages,
temperature: config.temperature,
response_format: { type: "json_object" },
...(stream ? { stream: true } : {}),
}),
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`OpenRouter API error ${response.status}: ${body}`);
}
return await parseFn(response, abortSignal);
} catch (err) {
if (attempt >= config.maxRetries) {
throw err;
}
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
}
}
return null;
}
/**
* Parse a non-streaming JSON response.
*/
function parseNonStreaming(response: Response): Promise<string | null> {
return response.json().then((data: unknown) => {
const typed = data as {
choices?: Array<{ message?: { content?: string } }>;
};
return typed.choices?.[0]?.message?.content ?? null;
});
}
/**
* Parse a streaming SSE response, accumulating chunks into a single string.
*/
async function parseStreaming(
response: Response,
abortSignal?: AbortSignal,
): Promise<string | null> {
if (!response.body) {
throw new Error("No response body for streaming request");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulated = "";
let buffer = "";
for (;;) {
// Check abort between chunks for responsive cancellation
if (abortSignal?.aborted) {
reader.cancel().catch(() => {});
return null;
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE lines
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data: ")) continue;
const data = trimmed.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string } }>;
};
const chunk = parsed.choices?.[0]?.delta?.content;
if (chunk) {
accumulated += chunk;
}
} catch {
// Skip malformed SSE chunks
}
}
}
return accumulated || null;
}
export async function callOpenRouter(
config: ExtractionConfig,
prompt: string | Array<{ role: string; content: string }>,
abortSignal?: AbortSignal,
): Promise<string | null> {
const messages = typeof prompt === "string" ? [{ role: "user", content: prompt }] : prompt;
return openRouterRequest(config, messages, abortSignal, false, parseNonStreaming);
}
/**
* Streaming variant of callOpenRouter. Uses the streaming API to receive chunks
* incrementally, allowing earlier cancellation via abort signal and better
* latency characteristics for long responses.
*
* Accumulates all chunks into a single response string since extraction
* uses JSON mode (which requires the complete object to parse).
*/
export async function callOpenRouterStream(
config: ExtractionConfig,
prompt: string | Array<{ role: string; content: string }>,
abortSignal?: AbortSignal,
): Promise<string | null> {
const messages = typeof prompt === "string" ? [{ role: "user", content: prompt }] : prompt;
return openRouterRequest(config, messages, abortSignal, true, parseStreaming);
}
/**
* Check if an error is transient (network/timeout) vs permanent (JSON parse, etc.)
*/
export function isTransientError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
const name =
typeof (err as { name?: unknown }).name === "string" ? (err as { name: string }).name : "";
const message =
typeof (err as { message?: unknown }).message === "string"
? (err as { message: string }).message
: "";
const msg = message.toLowerCase();
return (
name === "AbortError" ||
name === "TimeoutError" ||
msg.includes("timeout") ||
msg.includes("econnrefused") ||
msg.includes("econnreset") ||
msg.includes("etimedout") ||
msg.includes("enotfound") ||
msg.includes("network") ||
msg.includes("fetch failed") ||
msg.includes("socket hang up") ||
msg.includes("api error 429") ||
msg.includes("api error 502") ||
msg.includes("api error 503") ||
msg.includes("api error 504")
);
}

View File

@@ -0,0 +1,135 @@
/**
* Message extraction utilities for the memory pipeline.
*
* Extracts and cleans user/assistant messages from the raw event.messages
* array, stripping channel wrappers, injected context, tool output, and
* other noise so downstream consumers (attention gate, memory store) see
* only the substantive text.
*/
// ============================================================================
// Core Extraction
// ============================================================================
/**
* Extract text blocks from messages with a given role, apply a strip function,
* and filter out short results. Handles both string content and content block arrays.
*/
function extractMessagesByRole(
messages: unknown[],
role: string,
stripFn: (text: string) => string,
): string[] {
const texts: string[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
continue;
}
const msgObj = msg as Record<string, unknown>;
if (msgObj.role !== role) {
continue;
}
const content = msgObj.content;
if (typeof content === "string") {
texts.push(content);
continue;
}
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as Record<string, unknown>).type === "text" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
texts.push((block as Record<string, unknown>).text as string);
}
}
}
}
return texts.map(stripFn).filter((t) => t.length >= 10);
}
// ============================================================================
// User Message Extraction
// ============================================================================
/**
* Extract user message texts from the event.messages array.
*/
export function extractUserMessages(messages: unknown[]): string[] {
return extractMessagesByRole(messages, "user", stripMessageWrappers);
}
/**
* Strip injected context, channel metadata wrappers, and system prefixes
* so the attention gate sees only the raw user text.
* Exported for use by the cleanup command.
*/
export function stripMessageWrappers(text: string): string {
let s = text;
// Injected context from memory system
s = s.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g, "");
s = s.replace(/<core-memory-refresh>[\s\S]*?<\/core-memory-refresh>\s*/g, "");
s = s.replace(/<system>[\s\S]*?<\/system>\s*/g, "");
// File attachments (PDFs, images, etc. forwarded inline by channels)
s = s.replace(/<file\b[^>]*>[\s\S]*?<\/file>\s*/g, "");
// Media attachment preamble (appears before Telegram wrapper)
s = s.replace(/^\[media attached:[^\]]*\]\s*(?:To send an image[^\n]*\n?)*/i, "");
// System exec output blocks (may appear before Telegram wrapper)
s = s.replace(/^(?:System:\s*\[[^\]]*\][^\n]*\n?)+/gi, "");
// Voice chat timestamp prefix: [Tue 2026-02-10 19:41 GMT+8]
s = s.replace(
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+GMT[+-]\d+\]\s*/i,
"",
);
// Conversation info metadata block (gateway routing context with JSON code fence)
s = s.replace(/Conversation info\s*\(untrusted metadata\):\s*```[\s\S]*?```\s*/g, "");
// Queued message batch header and separators
s = s.replace(/^\[Queued messages while agent was busy\]\s*/i, "");
s = s.replace(/---\s*Queued #\d+\s*/g, "");
// Telegram wrapper — may now be at start after previous strips
s = s.replace(/^\s*\[Telegram\s[^\]]+\]\s*/i, "");
// "[message_id: ...]" suffix (Telegram and other channel IDs)
s = s.replace(/\n?\[message_id:\s*[^\]]+\]\s*$/i, "");
// Slack wrapper — "[Slack <workspace> #channel @user] MESSAGE [slack message id: ...]"
s = s.replace(/^\s*\[Slack\s[^\]]+\]\s*/i, "");
s = s.replace(/\n?\[slack message id:\s*[^\]]*\]\s*$/i, "");
return s.trim();
}
// ============================================================================
// Assistant Message Extraction
// ============================================================================
/**
* Strip tool-use, thinking, and code-output blocks from assistant messages
* so the attention gate sees only the substantive assistant text.
*/
export function stripAssistantWrappers(text: string): string {
let s = text;
// Tool-use / tool-result / function_call blocks
s = s.replace(/<tool_use>[\s\S]*?<\/tool_use>\s*/g, "");
s = s.replace(/<tool_result>[\s\S]*?<\/tool_result>\s*/g, "");
s = s.replace(/<function_call>[\s\S]*?<\/function_call>\s*/g, "");
// Thinking tags
s = s.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, "");
s = s.replace(/<antThinking>[\s\S]*?<\/antThinking>\s*/g, "");
// Code execution output
s = s.replace(/<code_output>[\s\S]*?<\/code_output>\s*/g, "");
return s.trim();
}
/**
* Extract assistant message texts from the event.messages array.
*/
export function extractAssistantMessages(messages: unknown[]): string[] {
return extractMessagesByRole(messages, "assistant", stripAssistantWrappers);
}

View File

@@ -0,0 +1,332 @@
/**
* Tests for mid-session core memory refresh feature.
*
* Verifies that core memories are re-injected when context usage exceeds threshold.
* Tests config parsing, threshold calculation, shouldRefresh logic, and edge cases.
*/
import { describe, it, expect } from "vitest";
// ============================================================================
// Config parsing for refreshAtContextPercent
// ============================================================================
describe("mid-session core memory refresh", () => {
describe("config parsing", () => {
it("should accept valid refreshAtContextPercent values", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 50 },
});
expect(config.coreMemory.refreshAtContextPercent).toBe(50);
});
it("should accept refreshAtContextPercent of 1 (minimum)", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 1 },
});
expect(config.coreMemory.refreshAtContextPercent).toBe(1);
});
it("should accept refreshAtContextPercent of 100 (maximum)", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 100 },
});
expect(config.coreMemory.refreshAtContextPercent).toBe(100);
});
it("should treat refreshAtContextPercent of 0 as disabled (undefined)", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 0 },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
it("should treat negative refreshAtContextPercent as disabled (undefined)", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: -10 },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
it("should throw for refreshAtContextPercent over 100", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
expect(() =>
memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { refreshAtContextPercent: 150 },
}),
).toThrow("coreMemory.refreshAtContextPercent must be between 1 and 100");
});
it("should default to undefined when coreMemory section is omitted", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
it("should default to undefined when refreshAtContextPercent is omitted", async () => {
const { memoryNeo4jConfigSchema } = await import("./config.js");
const config = memoryNeo4jConfigSchema.parse({
neo4j: { uri: "bolt://localhost:7687", user: "neo4j", password: "test" },
embedding: { provider: "ollama" },
coreMemory: { enabled: true },
});
expect(config.coreMemory.refreshAtContextPercent).toBeUndefined();
});
});
// ============================================================================
// shouldRefresh logic (tests the decision flow from index.ts)
// ============================================================================
describe("shouldRefresh decision logic", () => {
// These tests mirror the logic from index.ts lines 893-916:
// 1. Skip if contextWindowTokens or estimatedUsedTokens not available
// 2. Calculate usagePercent = (estimatedUsedTokens / contextWindowTokens) * 100
// 3. Skip if usagePercent < refreshThreshold
// 4. Skip if tokens since last refresh < MIN_TOKENS_SINCE_REFRESH (10_000)
// 5. Otherwise, refresh
const MIN_TOKENS_SINCE_REFRESH = 10_000;
function shouldRefresh(params: {
contextWindowTokens: number | undefined;
estimatedUsedTokens: number | undefined;
refreshThreshold: number;
lastRefreshTokens: number;
}): boolean {
const { contextWindowTokens, estimatedUsedTokens, refreshThreshold, lastRefreshTokens } =
params;
// Skip if context info not available
if (!contextWindowTokens || !estimatedUsedTokens) {
return false;
}
const usagePercent = (estimatedUsedTokens / contextWindowTokens) * 100;
// Only refresh if we've crossed the threshold
if (usagePercent < refreshThreshold) {
return false;
}
// Check if we've already refreshed recently
const tokensSinceRefresh = estimatedUsedTokens - lastRefreshTokens;
if (tokensSinceRefresh < MIN_TOKENS_SINCE_REFRESH) {
return false;
}
return true;
}
it("should trigger refresh when usage exceeds threshold and enough tokens accumulated", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 120_000, // 60%
refreshThreshold: 50,
lastRefreshTokens: 0, // Never refreshed
}),
).toBe(true);
});
it("should not trigger when usage is below threshold", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 80_000, // 40%
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(false);
});
it("should not trigger when not enough tokens since last refresh", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 105_000, // 52.5%
refreshThreshold: 50,
lastRefreshTokens: 100_000, // Only 5k tokens since last refresh
}),
).toBe(false);
});
it("should trigger when enough tokens accumulated since last refresh", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 115_000, // 57.5%
refreshThreshold: 50,
lastRefreshTokens: 100_000, // 15k tokens since last refresh
}),
).toBe(true);
});
it("should not trigger when contextWindowTokens is undefined", () => {
expect(
shouldRefresh({
contextWindowTokens: undefined,
estimatedUsedTokens: 120_000,
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(false);
});
it("should not trigger when estimatedUsedTokens is undefined", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: undefined,
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(false);
});
it("should handle 0% usage (empty context)", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 0,
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(false);
});
it("should handle 100% usage", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 200_000, // 100%
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(true);
});
it("should handle exact threshold boundary (50% == 50% threshold)", () => {
// usagePercent == refreshThreshold: usagePercent < refreshThreshold is false, so it proceeds
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 100_000, // exactly 50%
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(true);
});
it("should handle threshold of 1 (refresh almost immediately)", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 15_000, // 7.5%
refreshThreshold: 1,
lastRefreshTokens: 0,
}),
).toBe(true);
});
it("should handle threshold of 100 (refresh only at full context)", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 190_000, // 95%
refreshThreshold: 100,
lastRefreshTokens: 0,
}),
).toBe(false);
});
it("should allow first refresh even when lastRefreshTokens is 0", () => {
expect(
shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 110_000,
refreshThreshold: 50,
lastRefreshTokens: 0,
}),
).toBe(true);
});
it("should support multiple refresh cycles with cumulative token growth", () => {
// First refresh at 110k tokens
const firstResult = shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 110_000,
refreshThreshold: 50,
lastRefreshTokens: 0,
});
expect(firstResult).toBe(true);
// Second attempt too soon (only 5k since first)
const secondResult = shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 115_000,
refreshThreshold: 50,
lastRefreshTokens: 110_000,
});
expect(secondResult).toBe(false);
// Third attempt after enough growth (15k since first refresh)
const thirdResult = shouldRefresh({
contextWindowTokens: 200_000,
estimatedUsedTokens: 125_000,
refreshThreshold: 50,
lastRefreshTokens: 110_000,
});
expect(thirdResult).toBe(true);
});
});
// ============================================================================
// Output format
// ============================================================================
describe("refresh output format", () => {
it("should format core memories as XML-wrapped bullet list", () => {
const coreMemories = [
{ text: "User prefers TypeScript over JavaScript" },
{ text: "User works at Acme Corp" },
];
const content = coreMemories.map((m) => `- ${m.text}`).join("\n");
const output = `<core-memory-refresh>\nReminder of persistent context (you may have seen this earlier, re-stating for recency):\n${content}\n</core-memory-refresh>`;
expect(output).toContain("<core-memory-refresh>");
expect(output).toContain("</core-memory-refresh>");
expect(output).toContain("- User prefers TypeScript over JavaScript");
expect(output).toContain("- User works at Acme Corp");
});
it("should handle single core memory", () => {
const coreMemories = [{ text: "Only memory" }];
const content = coreMemories.map((m) => `- ${m.text}`).join("\n");
const output = `<core-memory-refresh>\nReminder of persistent context (you may have seen this earlier, re-stating for recency):\n${content}\n</core-memory-refresh>`;
expect(output).toContain("- Only memory");
expect(output.match(/^- /gm)?.length).toBe(1);
});
});
});

View File

@@ -0,0 +1,327 @@
/**
* Tests for entity deduplication in neo4j-client.ts.
*
* Tests findDuplicateEntityPairs() and mergeEntityPair() using mocked Neo4j driver.
* Verifies substring-matching logic, mention-count based decisions, and merge behavior.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Neo4jMemoryClient } from "./neo4j-client.js";
// ============================================================================
// Test Helpers
// ============================================================================
function createMockSession() {
return {
run: vi.fn().mockResolvedValue({ records: [] }),
close: vi.fn().mockResolvedValue(undefined),
executeWrite: vi.fn(
async (work: (tx: { run: ReturnType<typeof vi.fn> }) => Promise<unknown>) => {
const mockTx = { run: vi.fn().mockResolvedValue({ records: [] }) };
return work(mockTx);
},
),
};
}
function createMockDriver() {
return {
session: vi.fn().mockReturnValue(createMockSession()),
close: vi.fn().mockResolvedValue(undefined),
};
}
function createMockLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
function mockRecord(data: Record<string, unknown>) {
return {
get: (key: string) => data[key],
};
}
// ============================================================================
// Entity Deduplication Tests
// ============================================================================
describe("Entity Deduplication", () => {
let client: Neo4jMemoryClient;
let mockDriver: ReturnType<typeof createMockDriver>;
let mockSession: ReturnType<typeof createMockSession>;
let mockLogger: ReturnType<typeof createMockLogger>;
beforeEach(() => {
mockLogger = createMockLogger();
mockDriver = createMockDriver();
mockSession = createMockSession();
mockDriver.session.mockReturnValue(mockSession);
client = new Neo4jMemoryClient("bolt://localhost:7687", "neo4j", "password", 1024, mockLogger);
(client as any).driver = mockDriver;
(client as any).indexesReady = true;
});
// --------------------------------------------------------------------------
// findDuplicateEntityPairs()
// --------------------------------------------------------------------------
describe("findDuplicateEntityPairs", () => {
it("finds substring matches: 'tarun' + 'tarun sukhani' (same type)", async () => {
mockSession.run.mockResolvedValueOnce({
records: [
mockRecord({
id1: "e1",
name1: "tarun",
mc1: 5,
id2: "e2",
name2: "tarun sukhani",
mc2: 3,
}),
],
});
const pairs = await client.findDuplicateEntityPairs();
expect(pairs).toHaveLength(1);
// "tarun" has more mentions (5 > 3), so it should be kept
expect(pairs[0].keepId).toBe("e1");
expect(pairs[0].keepName).toBe("tarun");
expect(pairs[0].removeId).toBe("e2");
expect(pairs[0].removeName).toBe("tarun sukhani");
});
it("keeps entity with more mentions regardless of name length", async () => {
mockSession.run.mockResolvedValueOnce({
records: [
mockRecord({
id1: "e1",
name1: "fish speech",
mc1: 2,
id2: "e2",
name2: "fish speech s1 mini",
mc2: 10,
}),
],
});
const pairs = await client.findDuplicateEntityPairs();
expect(pairs).toHaveLength(1);
// "fish speech s1 mini" has more mentions (10 > 2), so it should be kept
expect(pairs[0].keepId).toBe("e2");
expect(pairs[0].keepName).toBe("fish speech s1 mini");
expect(pairs[0].removeId).toBe("e1");
expect(pairs[0].removeName).toBe("fish speech");
});
it("keeps shorter name when mentions are equal", async () => {
mockSession.run.mockResolvedValueOnce({
records: [
mockRecord({
id1: "e1",
name1: "aaditya",
mc1: 5,
id2: "e2",
name2: "aaditya sukhani",
mc2: 5,
}),
],
});
const pairs = await client.findDuplicateEntityPairs();
expect(pairs).toHaveLength(1);
// Equal mentions, so keep the shorter name ("aaditya")
expect(pairs[0].keepId).toBe("e1");
expect(pairs[0].keepName).toBe("aaditya");
expect(pairs[0].removeId).toBe("e2");
expect(pairs[0].removeName).toBe("aaditya sukhani");
});
it("returns empty array when no duplicates exist", async () => {
mockSession.run.mockResolvedValueOnce({ records: [] });
const pairs = await client.findDuplicateEntityPairs();
expect(pairs).toHaveLength(0);
});
it("handles multiple duplicate pairs", async () => {
mockSession.run.mockResolvedValueOnce({
records: [
mockRecord({
id1: "e1",
name1: "tarun",
mc1: 5,
id2: "e2",
name2: "tarun sukhani",
mc2: 3,
}),
mockRecord({
id1: "e3",
name1: "fish speech",
mc1: 2,
id2: "e4",
name2: "fish speech s1 mini",
mc2: 8,
}),
],
});
const pairs = await client.findDuplicateEntityPairs();
expect(pairs).toHaveLength(2);
});
it("handles NULL mention counts (treats as 0)", async () => {
mockSession.run.mockResolvedValueOnce({
records: [
mockRecord({
id1: "e1",
name1: "neo4j",
mc1: null,
id2: "e2",
name2: "neo4j database",
mc2: null,
}),
],
});
const pairs = await client.findDuplicateEntityPairs();
expect(pairs).toHaveLength(1);
// Both NULL (treated as 0), so keep the shorter name
expect(pairs[0].keepId).toBe("e1");
expect(pairs[0].keepName).toBe("neo4j");
});
it("passes the Cypher query with substring matching and type constraint", async () => {
mockSession.run.mockResolvedValueOnce({ records: [] });
await client.findDuplicateEntityPairs();
const query = mockSession.run.mock.calls[0][0] as string;
// Verify the query checks same type
expect(query).toContain("e1.type = e2.type");
// Verify the query checks CONTAINS in both directions
expect(query).toContain("e1.name CONTAINS e2.name");
expect(query).toContain("e2.name CONTAINS e1.name");
// Verify minimum name length filter
expect(query).toContain("size(e1.name) > 2");
});
});
// --------------------------------------------------------------------------
// mergeEntityPair()
// --------------------------------------------------------------------------
describe("mergeEntityPair", () => {
it("transfers MENTIONS and deletes source entity", async () => {
// mergeEntityPair uses executeWrite, so we need to set up the mock transaction
const mockTx = {
run: vi
.fn()
.mockResolvedValueOnce({
// Transfer MENTIONS
records: [mockRecord({ transferred: 3 })],
})
.mockResolvedValueOnce({
// Update mentionCount
records: [],
})
.mockResolvedValueOnce({
// Delete removed entity
records: [],
}),
};
mockSession.executeWrite.mockImplementationOnce(async (work: any) => work(mockTx));
const result = await client.mergeEntityPair("keep-id", "remove-id");
expect(result).toBe(true);
// Should have been called 3 times: transfer, update count, delete
expect(mockTx.run).toHaveBeenCalledTimes(3);
// Verify transfer query
const transferQuery = mockTx.run.mock.calls[0][0] as string;
expect(transferQuery).toContain("MERGE (m)-[:MENTIONS]->(keep)");
expect(transferQuery).toContain("DELETE r");
// Verify update mentionCount
const updateQuery = mockTx.run.mock.calls[1][0] as string;
expect(updateQuery).toContain("mentionCount");
// Verify delete query
const deleteQuery = mockTx.run.mock.calls[2][0] as string;
expect(deleteQuery).toContain("DETACH DELETE e");
});
it("skips mentionCount update when no relationships to transfer", async () => {
const mockTx = {
run: vi
.fn()
.mockResolvedValueOnce({
// Transfer MENTIONS — 0 transferred
records: [mockRecord({ transferred: 0 })],
})
.mockResolvedValueOnce({
// Delete removed entity (mentionCount update is skipped)
records: [],
}),
};
mockSession.executeWrite.mockImplementationOnce(async (work: any) => work(mockTx));
const result = await client.mergeEntityPair("keep-id", "remove-id");
expect(result).toBe(true);
// Only 2 calls: transfer (0 results) and delete (skip update)
expect(mockTx.run).toHaveBeenCalledTimes(2);
});
it("returns false on error", async () => {
mockSession.executeWrite.mockRejectedValueOnce(new Error("Neo4j connection lost"));
const result = await client.mergeEntityPair("keep-id", "remove-id");
expect(result).toBe(false);
});
});
// --------------------------------------------------------------------------
// reconcileEntityMentionCounts()
// --------------------------------------------------------------------------
describe("reconcileEntityMentionCounts", () => {
it("updates entities with NULL mentionCount", async () => {
mockSession.run.mockResolvedValueOnce({
records: [mockRecord({ updated: 42 })],
});
const updated = await client.reconcileEntityMentionCounts();
expect(updated).toBe(42);
const query = mockSession.run.mock.calls[0][0] as string;
expect(query).toContain("mentionCount IS NULL");
expect(query).toContain("SET e.mentionCount = actual");
});
it("returns 0 when all entities have mentionCount set", async () => {
mockSession.run.mockResolvedValueOnce({
records: [mockRecord({ updated: 0 })],
});
const updated = await client.reconcileEntityMentionCounts();
expect(updated).toBe(0);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
{
"id": "memory-neo4j",
"kind": "memory",
"uiHints": {
"embedding.provider": {
"label": "Embedding Provider",
"placeholder": "openai",
"help": "Provider for embeddings: 'openai' or 'ollama'"
},
"embedding.apiKey": {
"label": "API Key",
"sensitive": true,
"placeholder": "sk-proj-...",
"help": "API key for OpenAI embeddings (not needed for Ollama)"
},
"embedding.model": {
"label": "Embedding Model",
"placeholder": "text-embedding-3-small",
"help": "Embedding model to use (e.g., text-embedding-3-small for OpenAI, mxbai-embed-large for Ollama)"
},
"embedding.baseUrl": {
"label": "Base URL",
"placeholder": "http://localhost:11434",
"help": "Base URL for Ollama API (optional)"
},
"neo4j.uri": {
"label": "Neo4j URI",
"placeholder": "bolt://localhost:7687",
"help": "Bolt connection URI for your Neo4j instance"
},
"neo4j.user": {
"label": "Neo4j Username",
"placeholder": "neo4j"
},
"neo4j.password": {
"label": "Neo4j Password",
"sensitive": true
},
"autoCapture": {
"label": "Auto-Capture",
"help": "Automatically capture important information from conversations"
},
"autoRecall": {
"label": "Auto-Recall",
"help": "Automatically inject relevant memories into context"
},
"autoRecallMinScore": {
"label": "Auto-Recall Min Score",
"help": "Minimum similarity score (0-1) for auto-recall results (default: 0.25)"
},
"coreMemory.enabled": {
"label": "Core Memory",
"help": "Enable core memory bootstrap (top memories auto-loaded into context)"
},
"coreMemory.refreshAtContextPercent": {
"label": "Core Memory Refresh %",
"help": "Re-inject core memories when context usage reaches this percentage (1-100, optional)"
},
"extraction.apiKey": {
"label": "Extraction API Key",
"sensitive": true,
"placeholder": "sk-or-v1-...",
"help": "API key for extraction LLM (not needed for Ollama/local models)"
},
"extraction.model": {
"label": "Extraction Model",
"placeholder": "google/gemini-2.0-flash-001",
"help": "Model for entity extraction (e.g., google/gemini-2.0-flash-001 for OpenRouter, llama3.1:8b for Ollama)"
},
"extraction.baseUrl": {
"label": "Extraction Base URL",
"placeholder": "https://openrouter.ai/api/v1",
"help": "Base URL for extraction API (e.g., https://openrouter.ai/api/v1 or http://localhost:11434/v1 for Ollama)"
},
"graphSearchDepth": {
"label": "Graph Search Depth",
"help": "Maximum relationship hops for graph search spreading activation (1-3, default: 1)"
},
"decayCurves": {
"label": "Decay Curves",
"help": "Per-category decay curve overrides. Example: {\"fact\": {\"halfLifeDays\": 60}, \"other\": {\"halfLifeDays\": 14}}"
},
"sleepCycle.auto": {
"label": "Auto Sleep Cycle",
"help": "Automatically run memory consolidation (dedup, extraction, decay) daily at 3:00 AM local time (default: on)"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"embedding": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": ["openai", "ollama"]
},
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
}
}
},
"neo4j": {
"type": "object",
"additionalProperties": false,
"properties": {
"uri": {
"type": "string"
},
"user": {
"type": "string"
},
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": ["uri"]
},
"autoCapture": {
"type": "boolean"
},
"autoRecall": {
"type": "boolean"
},
"autoRecallMinScore": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"coreMemory": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"refreshAtContextPercent": {
"type": "number",
"minimum": 1,
"maximum": 100
}
}
},
"extraction": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
}
}
},
"graphSearchDepth": {
"type": "number",
"minimum": 1,
"maximum": 3
},
"decayCurves": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"halfLifeDays": {
"type": "number",
"minimum": 1
}
},
"required": ["halfLifeDays"]
}
},
"autoRecallSkipPattern": {
"type": "string",
"description": "RegExp pattern to skip auto-recall for matching session keys (e.g. voice|realtime)"
},
"autoCaptureSkipPattern": {
"type": "string",
"description": "RegExp pattern to skip auto-capture for matching session keys (e.g. voice|realtime)"
},
"sleepCycle": {
"type": "object",
"additionalProperties": false,
"properties": {
"auto": { "type": "boolean" }
}
}
},
"required": ["neo4j"]
}
}

View File

@@ -0,0 +1,19 @@
{
"name": "@openclaw/memory-neo4j",
"version": "2026.2.2",
"description": "OpenClaw Neo4j-backed long-term memory plugin with three-signal hybrid search, entity extraction, and knowledge graph",
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.48",
"neo4j-driver": "^5.27.0",
"openai": "^6.17.0"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,224 @@
/**
* Tests for schema.ts — Schema Validation & Helpers.
*
* Tests the exported pure functions: escapeLucene(), validateRelationshipType(),
* and the exported constants and types.
*/
import { describe, it, expect } from "vitest";
import type { MemorySource } from "./schema.js";
import {
escapeLucene,
validateRelationshipType,
ALLOWED_RELATIONSHIP_TYPES,
MEMORY_CATEGORIES,
ENTITY_TYPES,
} from "./schema.js";
// ============================================================================
// escapeLucene()
// ============================================================================
describe("escapeLucene", () => {
it("should return normal text unchanged", () => {
expect(escapeLucene("hello world")).toBe("hello world");
});
it("should return empty string unchanged", () => {
expect(escapeLucene("")).toBe("");
});
it("should escape plus sign", () => {
expect(escapeLucene("a+b")).toBe("a\\+b");
});
it("should escape minus sign", () => {
expect(escapeLucene("a-b")).toBe("a\\-b");
});
it("should escape ampersand", () => {
expect(escapeLucene("a&b")).toBe("a\\&b");
});
it("should escape pipe", () => {
expect(escapeLucene("a|b")).toBe("a\\|b");
});
it("should escape exclamation mark", () => {
expect(escapeLucene("hello!")).toBe("hello\\!");
});
it("should escape parentheses", () => {
expect(escapeLucene("(group)")).toBe("\\(group\\)");
});
it("should escape curly braces", () => {
expect(escapeLucene("{range}")).toBe("\\{range\\}");
});
it("should escape square brackets", () => {
expect(escapeLucene("[range]")).toBe("\\[range\\]");
});
it("should escape caret", () => {
expect(escapeLucene("boost^2")).toBe("boost\\^2");
});
it("should escape double quotes", () => {
expect(escapeLucene('"exact"')).toBe('\\"exact\\"');
});
it("should escape tilde", () => {
expect(escapeLucene("fuzzy~")).toBe("fuzzy\\~");
});
it("should escape asterisk", () => {
expect(escapeLucene("wild*")).toBe("wild\\*");
});
it("should escape question mark", () => {
expect(escapeLucene("single?")).toBe("single\\?");
});
it("should escape colon", () => {
expect(escapeLucene("field:value")).toBe("field\\:value");
});
it("should escape backslash", () => {
expect(escapeLucene("path\\file")).toBe("path\\\\file");
});
it("should escape forward slash", () => {
expect(escapeLucene("a/b")).toBe("a\\/b");
});
it("should escape multiple special characters in one string", () => {
expect(escapeLucene("(a+b) && c*")).toBe("\\(a\\+b\\) \\&\\& c\\*");
});
it("should handle mixed normal and special characters", () => {
expect(escapeLucene("hello world! [test]")).toBe("hello world\\! \\[test\\]");
});
it("should handle strings with only special characters", () => {
expect(escapeLucene("+-")).toBe("\\+\\-");
});
});
// ============================================================================
// validateRelationshipType()
// ============================================================================
describe("validateRelationshipType", () => {
describe("valid relationship types", () => {
it("should accept WORKS_AT", () => {
expect(validateRelationshipType("WORKS_AT")).toBe(true);
});
it("should accept LIVES_AT", () => {
expect(validateRelationshipType("LIVES_AT")).toBe(true);
});
it("should accept KNOWS", () => {
expect(validateRelationshipType("KNOWS")).toBe(true);
});
it("should accept MARRIED_TO", () => {
expect(validateRelationshipType("MARRIED_TO")).toBe(true);
});
it("should accept PREFERS", () => {
expect(validateRelationshipType("PREFERS")).toBe(true);
});
it("should accept DECIDED", () => {
expect(validateRelationshipType("DECIDED")).toBe(true);
});
it("should accept RELATED_TO", () => {
expect(validateRelationshipType("RELATED_TO")).toBe(true);
});
it("should accept all ALLOWED_RELATIONSHIP_TYPES", () => {
for (const type of ALLOWED_RELATIONSHIP_TYPES) {
expect(validateRelationshipType(type)).toBe(true);
}
});
});
describe("invalid relationship types", () => {
it("should reject unknown relationship type", () => {
expect(validateRelationshipType("HATES")).toBe(false);
});
it("should reject empty string", () => {
expect(validateRelationshipType("")).toBe(false);
});
it("should be case sensitive — lowercase is rejected", () => {
expect(validateRelationshipType("works_at")).toBe(false);
});
it("should be case sensitive — mixed case is rejected", () => {
expect(validateRelationshipType("Works_At")).toBe(false);
});
it("should reject types with extra whitespace", () => {
expect(validateRelationshipType(" WORKS_AT ")).toBe(false);
});
it("should reject potential Cypher injection", () => {
expect(validateRelationshipType("WORKS_AT]->(n) DELETE n//")).toBe(false);
});
});
});
// ============================================================================
// Exported Constants
// ============================================================================
describe("exported constants", () => {
it("MEMORY_CATEGORIES should contain expected categories", () => {
expect(MEMORY_CATEGORIES).toContain("preference");
expect(MEMORY_CATEGORIES).toContain("fact");
expect(MEMORY_CATEGORIES).toContain("decision");
expect(MEMORY_CATEGORIES).toContain("entity");
expect(MEMORY_CATEGORIES).toContain("other");
});
it("ENTITY_TYPES should contain expected types", () => {
expect(ENTITY_TYPES).toContain("person");
expect(ENTITY_TYPES).toContain("organization");
expect(ENTITY_TYPES).toContain("location");
expect(ENTITY_TYPES).toContain("event");
expect(ENTITY_TYPES).toContain("concept");
});
it("ALLOWED_RELATIONSHIP_TYPES should be a Set", () => {
expect(ALLOWED_RELATIONSHIP_TYPES).toBeInstanceOf(Set);
expect(ALLOWED_RELATIONSHIP_TYPES.size).toBe(7);
});
});
// ============================================================================
// MemorySource Type
// ============================================================================
describe("MemorySource type", () => {
it("should accept 'auto-capture-assistant' as a valid MemorySource value", () => {
// Type-level check: this assignment should compile without error
const source: MemorySource = "auto-capture-assistant";
expect(source).toBe("auto-capture-assistant");
});
it("should accept all MemorySource values", () => {
const sources: MemorySource[] = [
"user",
"auto-capture",
"auto-capture-assistant",
"memory-watcher",
"import",
];
expect(sources).toHaveLength(5);
});
});

View File

@@ -0,0 +1,206 @@
/**
* Graph schema types, Cypher query templates, and constants for memory-neo4j.
*/
// ============================================================================
// Shared Types
// ============================================================================
export type Logger = {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
// ============================================================================
// Node Types
// ============================================================================
export type MemoryCategory = "core" | "preference" | "fact" | "decision" | "entity" | "other";
export type EntityType = "person" | "organization" | "location" | "event" | "concept";
export type ExtractionStatus = "pending" | "complete" | "failed" | "skipped";
export type MemorySource =
| "user"
| "auto-capture"
| "auto-capture-assistant"
| "memory-watcher"
| "import";
export type MemoryNode = {
id: string;
text: string;
embedding: number[];
importance: number;
category: MemoryCategory;
source: MemorySource;
createdAt: string;
updatedAt: string;
extractionStatus: ExtractionStatus;
extractionRetries: number;
agentId: string;
sessionKey?: string;
retrievalCount: number;
lastRetrievedAt?: string;
taskId?: string; // Optional link to TASKS.md task (e.g., "TASK-001")
};
export type EntityNode = {
id: string;
name: string;
type: EntityType;
aliases: string[];
description?: string;
firstSeen: string;
lastSeen: string;
mentionCount: number;
};
export type TagNode = {
id: string;
name: string;
category: string;
createdAt: string;
};
// ============================================================================
// Extraction Types
// ============================================================================
export type ExtractedEntity = {
name: string;
type: EntityType;
aliases?: string[];
description?: string;
};
export type ExtractedRelationship = {
source: string;
target: string;
type: string;
confidence: number;
};
export type ExtractedTag = {
name: string;
category: string;
};
export type ExtractionResult = {
category?: MemoryCategory;
entities: ExtractedEntity[];
relationships: ExtractedRelationship[];
tags: ExtractedTag[];
};
// ============================================================================
// Search Types
// ============================================================================
export type SearchSignalResult = {
id: string;
text: string;
category: string;
importance: number;
createdAt: string;
score: number;
taskId?: string; // Optional link to TASKS.md task (e.g., "TASK-001")
};
export type SignalAttribution = {
rank: number; // 1-indexed, 0 = absent from this signal
score: number; // raw signal score, 0 = absent
};
export type HybridSearchResult = {
id: string;
text: string;
category: string;
importance: number;
createdAt: string;
score: number;
taskId?: string; // Optional link to TASKS.md task (e.g., "TASK-001")
signals?: {
vector: SignalAttribution;
bm25: SignalAttribution;
graph: SignalAttribution;
};
};
// ============================================================================
// Input Types
// ============================================================================
export type StoreMemoryInput = {
id: string;
text: string;
embedding: number[];
importance: number;
category: MemoryCategory;
source: MemorySource;
extractionStatus: ExtractionStatus;
agentId: string;
sessionKey?: string;
taskId?: string; // Optional link to TASKS.md task (e.g., "TASK-001")
};
export type MergeEntityInput = {
id: string;
name: string;
type: EntityType;
aliases?: string[];
description?: string;
};
// ============================================================================
// Constants
// ============================================================================
export const MEMORY_CATEGORIES = [
"core",
"preference",
"fact",
"decision",
"entity",
"other",
] as const;
export const ENTITY_TYPES = ["person", "organization", "location", "event", "concept"] as const;
export const ALLOWED_RELATIONSHIP_TYPES = new Set([
"WORKS_AT",
"LIVES_AT",
"KNOWS",
"MARRIED_TO",
"PREFERS",
"DECIDED",
"RELATED_TO",
]);
// ============================================================================
// Lucene Helpers
// ============================================================================
const LUCENE_SPECIAL_CHARS = /[+\-&|!(){}[\]^"~*?:\\/]/g;
/**
* Escape special characters for Lucene fulltext search queries.
*/
export function escapeLucene(query: string): string {
return query.replace(LUCENE_SPECIAL_CHARS, "\\$&");
}
/**
* Validate that a relationship type is in the allowed set.
* Prevents Cypher injection via dynamic relationship type.
*/
export function validateRelationshipType(type: string): boolean {
return ALLOWED_RELATIONSHIP_TYPES.has(type);
}
/**
* Create a canonical key for a pair of IDs (sorted for order-independence).
*/
export function makePairKey(a: string, b: string): string {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}

View File

@@ -0,0 +1,554 @@
/**
* Tests for search.ts — Hybrid Search & RRF Fusion.
*
* Tests the exported pure logic: classifyQuery(), getAdaptiveWeights(), and fuseWithConfidenceRRF().
* hybridSearch() is tested with mocked Neo4j client and Embeddings.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Embeddings } from "./embeddings.js";
import type { Neo4jMemoryClient } from "./neo4j-client.js";
import type { SearchSignalResult } from "./schema.js";
import {
classifyQuery,
getAdaptiveWeights,
fuseWithConfidenceRRF,
hybridSearch,
} from "./search.js";
// ============================================================================
// classifyQuery()
// ============================================================================
describe("classifyQuery", () => {
describe("short queries (1-2 words)", () => {
it("should classify a single word as 'short'", () => {
expect(classifyQuery("dogs")).toBe("short");
});
it("should classify two words as 'short'", () => {
expect(classifyQuery("best coffee")).toBe("short");
});
it("should handle whitespace-padded short queries", () => {
expect(classifyQuery(" hello ")).toBe("short");
});
});
describe("entity queries (proper nouns)", () => {
it("should classify a single capitalized word as 'entity' (proper noun detection)", () => {
expect(classifyQuery("TypeScript")).toBe("entity");
});
it("should classify query with proper noun as 'entity'", () => {
expect(classifyQuery("tell me about Tarun")).toBe("entity");
});
it("should classify query with organization name as 'entity'", () => {
expect(classifyQuery("what about Google")).toBe("entity");
});
it("should classify question patterns targeting entities", () => {
expect(classifyQuery("who is the CEO")).toBe("entity");
});
it("should classify 'where is' patterns as entity", () => {
expect(classifyQuery("where is the office")).toBe("entity");
});
it("should classify 'what does' patterns as entity", () => {
expect(classifyQuery("what does she do")).toBe("entity");
});
it("should not treat common words (The, Is, etc.) as entity indicators", () => {
// "The" and "Is" are excluded from capitalized word detection
// 3 words, no proper nouns detected, no question pattern -> default
expect(classifyQuery("this is fine")).toBe("default");
});
});
describe("long queries (5+ words)", () => {
it("should classify a 5-word query as 'long'", () => {
expect(classifyQuery("what is the best framework")).toBe("long");
});
it("should classify a longer sentence as 'long'", () => {
expect(classifyQuery("tell me about the history of programming languages")).toBe("long");
});
it("should classify a verbose question as 'long'", () => {
expect(classifyQuery("how do i configure the database connection")).toBe("long");
});
});
describe("default queries (3-4 words, no entities)", () => {
it("should classify a 3-word lowercase query as 'default'", () => {
expect(classifyQuery("my favorite color")).toBe("default");
});
it("should classify a 4-word lowercase query as 'default'", () => {
expect(classifyQuery("best practices for testing")).toBe("default");
});
});
describe("edge cases", () => {
it("should handle empty string", () => {
// Empty string splits to [""], length 1 -> "short"
expect(classifyQuery("")).toBe("short");
});
it("should handle only whitespace", () => {
// " ".trim() = "", splits to [""], length 1 -> "short"
expect(classifyQuery(" ")).toBe("short");
});
});
});
// ============================================================================
// getAdaptiveWeights()
// ============================================================================
describe("getAdaptiveWeights", () => {
describe("with graph enabled", () => {
it("should boost BM25 for short queries", () => {
const [vector, bm25, graph] = getAdaptiveWeights("short", true);
expect(bm25).toBeGreaterThan(vector);
expect(vector).toBe(0.8);
expect(bm25).toBe(1.2);
expect(graph).toBe(1.0);
});
it("should boost graph for entity queries", () => {
const [vector, bm25, graph] = getAdaptiveWeights("entity", true);
expect(graph).toBeGreaterThan(vector);
expect(graph).toBeGreaterThan(bm25);
expect(vector).toBe(0.8);
expect(bm25).toBe(1.0);
expect(graph).toBe(1.3);
});
it("should boost vector for long queries", () => {
const [vector, bm25, graph] = getAdaptiveWeights("long", true);
expect(vector).toBeGreaterThan(bm25);
expect(vector).toBeGreaterThan(graph);
expect(vector).toBe(1.2);
expect(bm25).toBe(0.7);
expect(graph).toBeCloseTo(0.8);
});
it("should return balanced weights for default queries", () => {
const [vector, bm25, graph] = getAdaptiveWeights("default", true);
expect(vector).toBe(1.0);
expect(bm25).toBe(1.0);
expect(graph).toBe(1.0);
});
});
describe("with graph disabled", () => {
it("should zero-out graph weight for short queries", () => {
const [vector, bm25, graph] = getAdaptiveWeights("short", false);
expect(graph).toBe(0);
expect(vector).toBe(0.8);
expect(bm25).toBe(1.2);
});
it("should zero-out graph weight for entity queries", () => {
const [, , graph] = getAdaptiveWeights("entity", false);
expect(graph).toBe(0);
});
it("should zero-out graph weight for long queries", () => {
const [, , graph] = getAdaptiveWeights("long", false);
expect(graph).toBe(0);
});
it("should zero-out graph weight for default queries", () => {
const [, , graph] = getAdaptiveWeights("default", false);
expect(graph).toBe(0);
});
});
});
// ============================================================================
// hybridSearch() — integration test with mocked dependencies
// ============================================================================
describe("hybridSearch", () => {
// Properly typed mocks matching the interfaces hybridSearch depends on.
// Using Pick<> to extract only the methods hybridSearch actually calls,
// so TypeScript will catch interface changes (e.g. renamed or removed methods).
type MockedDb = {
[K in keyof Pick<
Neo4jMemoryClient,
"vectorSearch" | "bm25Search" | "graphSearch" | "recordRetrievals"
>]: ReturnType<typeof vi.fn>;
};
type MockedEmbeddings = {
[K in keyof Pick<Embeddings, "embed" | "embedBatch">]: ReturnType<typeof vi.fn>;
};
const mockDb: MockedDb = {
vectorSearch: vi.fn(),
bm25Search: vi.fn(),
graphSearch: vi.fn(),
recordRetrievals: vi.fn(),
};
const mockEmbeddings: MockedEmbeddings = {
embed: vi.fn(),
embedBatch: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
mockEmbeddings.embed.mockResolvedValue([0.1, 0.2, 0.3]);
mockDb.recordRetrievals.mockResolvedValue(undefined);
});
function makeSignalResult(overrides: Partial<SearchSignalResult> = {}): SearchSignalResult {
return {
id: "mem-1",
text: "Test memory",
category: "fact",
importance: 0.7,
createdAt: "2025-01-01T00:00:00Z",
score: 0.9,
...overrides,
};
}
it("should return empty array when no signals return results", async () => {
mockDb.vectorSearch.mockResolvedValue([]);
mockDb.bm25Search.mockResolvedValue([]);
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
expect(results).toEqual([]);
expect(mockDb.recordRetrievals).not.toHaveBeenCalled();
});
it("should fuse results from vector and BM25 signals", async () => {
const vectorResult = makeSignalResult({ id: "mem-1", score: 0.95, text: "Vector match" });
const bm25Result = makeSignalResult({ id: "mem-2", score: 0.8, text: "BM25 match" });
mockDb.vectorSearch.mockResolvedValue([vectorResult]);
mockDb.bm25Search.mockResolvedValue([bm25Result]);
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
expect(results.length).toBe(2);
// Results should have scores normalized to 0-1
expect(results[0].score).toBeLessThanOrEqual(1);
expect(results[0].score).toBeGreaterThanOrEqual(0);
// First result should have the highest score (normalized to 1)
expect(results[0].score).toBe(1);
});
it("should deduplicate across signals (same memory in multiple signals)", async () => {
const sharedResult = makeSignalResult({ id: "mem-shared", score: 0.9 });
mockDb.vectorSearch.mockResolvedValue([sharedResult]);
mockDb.bm25Search.mockResolvedValue([{ ...sharedResult, score: 0.85 }]);
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
// Should only have one result (deduplicated by ID)
expect(results.length).toBe(1);
expect(results[0].id).toBe("mem-shared");
// Score should be higher than either individual signal (boosted by appearing in both)
expect(results[0].score).toBe(1); // It's the only result, so normalized to 1
});
it("should include graph signal when graphEnabled is true", async () => {
mockDb.vectorSearch.mockResolvedValue([]);
mockDb.bm25Search.mockResolvedValue([]);
mockDb.graphSearch.mockResolvedValue([
makeSignalResult({ id: "mem-graph", score: 0.7, text: "Graph result" }),
]);
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"tell me about Tarun",
5,
"agent-1",
true,
);
expect(mockDb.graphSearch).toHaveBeenCalled();
expect(results.length).toBe(1);
expect(results[0].id).toBe("mem-graph");
});
it("should not call graphSearch when graphEnabled is false", async () => {
mockDb.vectorSearch.mockResolvedValue([]);
mockDb.bm25Search.mockResolvedValue([]);
await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
expect(mockDb.graphSearch).not.toHaveBeenCalled();
});
it("should limit results to the requested count", async () => {
const manyResults = Array.from({ length: 10 }, (_, i) =>
makeSignalResult({ id: `mem-${i}`, score: 0.9 - i * 0.05 }),
);
mockDb.vectorSearch.mockResolvedValue(manyResults);
mockDb.bm25Search.mockResolvedValue([]);
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
3,
"agent-1",
false,
);
expect(results.length).toBe(3);
});
it("should record retrieval events for returned results", async () => {
mockDb.vectorSearch.mockResolvedValue([
makeSignalResult({ id: "mem-1" }),
makeSignalResult({ id: "mem-2" }),
]);
mockDb.bm25Search.mockResolvedValue([]);
await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
expect(mockDb.recordRetrievals).toHaveBeenCalledWith(["mem-1", "mem-2"]);
});
it("should silently handle recordRetrievals failure", async () => {
mockDb.vectorSearch.mockResolvedValue([makeSignalResult({ id: "mem-1" })]);
mockDb.bm25Search.mockResolvedValue([]);
mockDb.recordRetrievals.mockRejectedValue(new Error("DB connection lost"));
// Should not throw
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
expect(results.length).toBe(1);
});
it("should normalize scores to 0-1 range", async () => {
mockDb.vectorSearch.mockResolvedValue([
makeSignalResult({ id: "mem-1", score: 0.95 }),
makeSignalResult({ id: "mem-2", score: 0.5 }),
]);
mockDb.bm25Search.mockResolvedValue([]);
const results = await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
);
for (const r of results) {
expect(r.score).toBeGreaterThanOrEqual(0);
expect(r.score).toBeLessThanOrEqual(1);
}
});
it("should use candidateMultiplier option", async () => {
mockDb.vectorSearch.mockResolvedValue([]);
mockDb.bm25Search.mockResolvedValue([]);
await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
5,
"agent-1",
false,
{ candidateMultiplier: 8 },
);
// limit=5, multiplier=8 => candidateLimit = 40
expect(mockDb.vectorSearch).toHaveBeenCalledWith(expect.any(Array), 40, 0.1, "agent-1");
expect(mockDb.bm25Search).toHaveBeenCalledWith("test query", 40, "agent-1");
});
it("should pass default agentId when not specified", async () => {
mockDb.vectorSearch.mockResolvedValue([]);
mockDb.bm25Search.mockResolvedValue([]);
await hybridSearch(
mockDb as unknown as Neo4jMemoryClient,
mockEmbeddings as unknown as Embeddings,
"test query",
);
expect(mockDb.vectorSearch).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Number),
0.1,
"default",
);
});
});
// ============================================================================
// fuseWithConfidenceRRF()
// ============================================================================
describe("fuseWithConfidenceRRF", () => {
function makeSignal(id: string, score: number, text = `Memory ${id}`): SearchSignalResult {
return {
id,
text,
category: "fact",
importance: 0.7,
createdAt: "2025-01-01T00:00:00Z",
score,
};
}
it("should return empty array when all signals are empty", () => {
const result = fuseWithConfidenceRRF([[], [], []], 60, [1.0, 1.0, 1.0]);
expect(result).toEqual([]);
});
it("should handle a single signal with results", () => {
const signal = [makeSignal("a", 0.9), makeSignal("b", 0.5)];
const result = fuseWithConfidenceRRF([signal, [], []], 60, [1.0, 1.0, 1.0]);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("a");
expect(result[1].id).toBe("b");
// First result should have higher RRF score than second
expect(result[0].rrfScore).toBeGreaterThan(result[1].rrfScore);
});
it("should boost candidates appearing in multiple signals", () => {
const vectorSignal = [makeSignal("shared", 0.9), makeSignal("vec-only", 0.8)];
const bm25Signal = [makeSignal("shared", 0.85)];
const result = fuseWithConfidenceRRF([vectorSignal, bm25Signal, []], 60, [1.0, 1.0, 1.0]);
// "shared" should rank higher than "vec-only" despite similar scores
// because it appears in two signals
expect(result[0].id).toBe("shared");
expect(result[1].id).toBe("vec-only");
});
it("should handle ties (same score, same rank) consistently", () => {
const signal = [makeSignal("a", 0.5), makeSignal("b", 0.5)];
const result = fuseWithConfidenceRRF([signal], 60, [1.0]);
expect(result).toHaveLength(2);
// With same score, first in signal should have higher RRF (rank 1 vs rank 2)
expect(result[0].id).toBe("a");
expect(result[1].id).toBe("b");
});
it("should respect different k values", () => {
const signal = [makeSignal("a", 0.9), makeSignal("b", 0.5)];
// Small k amplifies rank differences, large k smooths them
const resultSmallK = fuseWithConfidenceRRF([signal], 1, [1.0]);
const resultLargeK = fuseWithConfidenceRRF([signal], 1000, [1.0]);
// The ratio between first and second should be larger with smaller k
const ratioSmallK = resultSmallK[0].rrfScore / resultSmallK[1].rrfScore;
const ratioLargeK = resultLargeK[0].rrfScore / resultLargeK[1].rrfScore;
expect(ratioSmallK).toBeGreaterThan(ratioLargeK);
});
it("should handle zero-score entries", () => {
const signal = [makeSignal("a", 0.9), makeSignal("b", 0)];
const result = fuseWithConfidenceRRF([signal], 60, [1.0]);
expect(result).toHaveLength(2);
// Zero score entry should have zero RRF contribution
expect(result[1].rrfScore).toBe(0);
expect(result[0].rrfScore).toBeGreaterThan(0);
});
it("should apply signal weights correctly", () => {
// Same item appears in two signals with different weights
const signal1 = [makeSignal("a", 0.8)];
const signal2 = [makeSignal("a", 0.8)];
const resultEqual = fuseWithConfidenceRRF([signal1, signal2], 60, [1.0, 1.0]);
const resultWeighted = fuseWithConfidenceRRF([signal1, signal2], 60, [2.0, 0.5]);
// Both should have the same item, but weighted version uses different signal contributions
expect(resultEqual[0].id).toBe("a");
expect(resultWeighted[0].id).toBe("a");
// With unequal weights, overall score differs
expect(resultEqual[0].rrfScore).not.toBeCloseTo(resultWeighted[0].rrfScore);
});
it("should sort results by RRF score descending", () => {
const signal1 = [makeSignal("low", 0.3)];
const signal2 = [makeSignal("high", 0.95)];
const signal3 = [makeSignal("mid", 0.6)];
const result = fuseWithConfidenceRRF([signal1, signal2, signal3], 60, [1.0, 1.0, 1.0]);
expect(result[0].id).toBe("high");
expect(result[1].id).toBe("mid");
expect(result[2].id).toBe("low");
});
it("should deduplicate within a single signal (keep first occurrence)", () => {
const signal = [
makeSignal("dup", 0.9),
makeSignal("dup", 0.5), // duplicate — should be ignored
makeSignal("other", 0.7),
];
const result = fuseWithConfidenceRRF([signal], 60, [1.0]);
// "dup" should appear once using its first occurrence (rank 1, score 0.9)
const dupEntry = result.find((r) => r.id === "dup");
expect(dupEntry).toBeDefined();
// Only 2 unique candidates
expect(result).toHaveLength(2);
});
});

View File

@@ -0,0 +1,315 @@
/**
* Three-signal hybrid search with query-adaptive RRF fusion.
*
* Combines:
* Signal 1: Vector similarity (HNSW cosine)
* Signal 2: BM25 full-text keyword matching
* Signal 3: Graph traversal (entity → MENTIONS ← memory)
*
* Fused using confidence-weighted Reciprocal Rank Fusion (RRF)
* with query-adaptive signal weights.
*
* Adapted from ontology project RRF implementation.
*/
import type { Embeddings } from "./embeddings.js";
import type { Neo4jMemoryClient } from "./neo4j-client.js";
import type {
HybridSearchResult,
Logger,
SearchSignalResult,
SignalAttribution,
} from "./schema.js";
// ============================================================================
// Query Classification
// ============================================================================
export type QueryType = "short" | "entity" | "long" | "default";
/**
* Classify a query to determine adaptive signal weights.
*
* - short (1-2 words): BM25 excels at exact keyword matching
* - entity (proper nouns detected): Graph traversal finds connected memories
* - long (5+ words): Vector captures semantic intent better
* - default: balanced weights
*/
export function classifyQuery(query: string): QueryType {
const words = query.trim().split(/\s+/);
const wordCount = words.length;
// Entity detection: check for capitalized words (proper nouns)
// Runs before word count so "John" or "TypeScript" are classified as entity
const commonWords =
/^(I|A|An|The|Is|Are|Was|Were|What|Who|Where|When|How|Why|Do|Does|Did|Find|Show|Get|Tell|Me|My|About|For)$/;
const capitalizedWords = words.filter((w) => /^[A-Z]/.test(w) && !commonWords.test(w));
if (capitalizedWords.length > 0) {
return "entity";
}
// Short queries: 1-2 words → boost BM25
if (wordCount <= 2) {
return "short";
}
// Question patterns targeting entities (3-4 word queries only,
// so generic long questions like "what is the best framework" fall through to "long")
if (wordCount <= 4 && /^(who|where|what)\s+(is|does|did|was|were)\s/i.test(query)) {
return "entity";
}
// Long queries: 5+ words → boost vector
if (wordCount >= 5) {
return "long";
}
return "default";
}
/**
* Get adaptive signal weights based on query type.
* Returns [vectorWeight, bm25Weight, graphWeight].
*
* Decision Q7: Query-adaptive RRF weights
* - Short → boost BM25 (keyword matching)
* - Entity → boost graph (relationship traversal)
* - Long → boost vector (semantic similarity)
*/
export function getAdaptiveWeights(
queryType: QueryType,
graphEnabled: boolean,
): [number, number, number] {
const graphBase = graphEnabled ? 1.0 : 0.0;
switch (queryType) {
case "short":
return [0.8, 1.2, graphBase * 1.0];
case "entity":
return [0.8, 1.0, graphBase * 1.3];
case "long":
return [1.2, 0.7, graphBase * 0.8];
case "default":
default:
return [1.0, 1.0, graphBase * 1.0];
}
}
// ============================================================================
// Confidence-Weighted RRF Fusion
// ============================================================================
type SignalEntry = {
rank: number; // 1-indexed
score: number; // 0-1 normalized
};
type FusedCandidate = {
id: string;
text: string;
category: string;
importance: number;
createdAt: string;
rrfScore: number;
taskId?: string;
signals: {
vector: SignalAttribution;
bm25: SignalAttribution;
graph: SignalAttribution;
};
};
/**
* Fuse multiple search signals using confidence-weighted RRF.
*
* Formula: RRF_conf(d) = Σ w_i × score_i(d) / (k + rank_i(d))
*
* Unlike standard RRF which only uses ranks, this variant preserves
* score magnitude: rank-1 with score 0.99 contributes more than
* rank-1 with score 0.55.
*
* Reference: Cormack et al. (2009), extended with confidence weighting.
*/
export function fuseWithConfidenceRRF(
signals: SearchSignalResult[][],
k: number,
weights: number[],
): FusedCandidate[] {
// Build per-signal rank/score lookups
const signalMaps: Map<string, SignalEntry>[] = signals.map((signal) => {
const map = new Map<string, SignalEntry>();
for (let i = 0; i < signal.length; i++) {
const entry = signal[i];
// If duplicate in same signal, keep first (higher ranked)
if (!map.has(entry.id)) {
map.set(entry.id, { rank: i + 1, score: entry.score });
}
}
return map;
});
// Collect all unique candidate IDs with their metadata
const candidateMetadata = new Map<
string,
{ text: string; category: string; importance: number; createdAt: string; taskId?: string }
>();
for (const signal of signals) {
for (const entry of signal) {
if (!candidateMetadata.has(entry.id)) {
candidateMetadata.set(entry.id, {
text: entry.text,
category: entry.category,
importance: entry.importance,
createdAt: entry.createdAt,
taskId: entry.taskId,
});
}
}
}
// Calculate confidence-weighted RRF score for each candidate
const results: FusedCandidate[] = [];
const NO_SIGNAL: SignalAttribution = { rank: 0, score: 0 };
for (const [id, meta] of candidateMetadata) {
let rrfScore = 0;
for (let i = 0; i < signalMaps.length; i++) {
const entry = signalMaps[i].get(id);
if (entry && entry.rank > 0) {
// Confidence-weighted: multiply by original score
rrfScore += weights[i] * entry.score * (1 / (k + entry.rank));
}
}
// Build per-signal attribution from the existing signal maps
const signals = {
vector: signalMaps[0]?.get(id) ?? NO_SIGNAL,
bm25: signalMaps[1]?.get(id) ?? NO_SIGNAL,
graph: signalMaps[2]?.get(id) ?? NO_SIGNAL,
};
results.push({
id,
text: meta.text,
category: meta.category,
importance: meta.importance,
createdAt: meta.createdAt,
rrfScore,
taskId: meta.taskId,
signals,
});
}
// Sort by RRF score descending
results.sort((a, b) => b.rrfScore - a.rrfScore);
return results;
}
// ============================================================================
// Hybrid Search Orchestrator
// ============================================================================
/**
* Perform a three-signal hybrid search with query-adaptive RRF fusion.
*
* 1. Embed the query
* 2. Classify query for adaptive weights
* 3. Run three signals in parallel
* 4. Fuse with confidence-weighted RRF
* 5. Return top results
*
* Graceful degradation: if any signal fails, RRF works with remaining signals.
* If graph search is not enabled (no extraction API key), uses 2-signal fusion.
*/
export async function hybridSearch(
db: Neo4jMemoryClient,
embeddings: Embeddings,
query: string,
limit: number = 5,
agentId: string = "default",
graphEnabled: boolean = false,
options: {
rrfK?: number;
candidateMultiplier?: number;
graphFiringThreshold?: number;
graphSearchDepth?: number;
logger?: Logger;
} = {},
): Promise<HybridSearchResult[]> {
// Guard against empty queries
if (!query.trim()) {
return [];
}
const {
rrfK = 60,
candidateMultiplier = 4,
graphFiringThreshold = 0.3,
graphSearchDepth = 1,
logger,
} = options;
const candidateLimit = Math.floor(Math.min(200, Math.max(1, limit * candidateMultiplier)));
// 1. Generate query embedding
const t0 = performance.now();
const queryEmbedding = await embeddings.embed(query);
const tEmbed = performance.now();
// 2. Classify query and get adaptive weights
const queryType = classifyQuery(query);
const weights = getAdaptiveWeights(queryType, graphEnabled);
// 3. Run signals in parallel
const [vectorResults, bm25Results, graphResults] = await Promise.all([
db.vectorSearch(queryEmbedding, candidateLimit, 0.1, agentId),
db.bm25Search(query, candidateLimit, agentId),
graphEnabled
? db.graphSearch(query, candidateLimit, graphFiringThreshold, agentId, graphSearchDepth)
: Promise.resolve([] as SearchSignalResult[]),
]);
const tSignals = performance.now();
// 4. Fuse with confidence-weighted RRF
const fused = fuseWithConfidenceRRF([vectorResults, bm25Results, graphResults], rrfK, weights);
const tFuse = performance.now();
// 5. Return top results, normalized to 0-100% display scores.
// Only normalize when maxRrf is above a minimum threshold to avoid
// inflating weak matches (e.g., a single low-score result becoming 1.0).
const maxRrf = fused.length > 0 ? fused[0].rrfScore : 0;
const MIN_RRF_FOR_NORMALIZATION = 0.01;
const normalizer = maxRrf >= MIN_RRF_FOR_NORMALIZATION ? 1 / maxRrf : 1;
const results = fused.slice(0, limit).map((r) => ({
id: r.id,
text: r.text,
category: r.category,
importance: r.importance,
createdAt: r.createdAt,
score: Math.min(1, r.rrfScore * normalizer), // Normalize to 0-1
taskId: r.taskId,
signals: r.signals,
}));
// 6. Record retrieval events (fire-and-forget for latency)
// This tracks which memories are actually being used, enabling
// retrieval-based importance adjustment.
if (results.length > 0) {
const memoryIds = results.map((r) => r.id);
db.recordRetrievals(memoryIds).catch(() => {
// Silently ignore - retrieval tracking is non-critical
});
}
// Log search timing breakdown
logger?.info?.(
`memory-neo4j: [bench] hybridSearch ${(tFuse - t0).toFixed(0)}ms (embed=${(tEmbed - t0).toFixed(0)}ms, signals=${(tSignals - tEmbed).toFixed(0)}ms, fuse=${(tFuse - tSignals).toFixed(0)}ms) ` +
`type=${queryType} vec=${vectorResults.length} bm25=${bm25Results.length} graph=${graphResults.length}${results.length} results`,
);
return results;
}

View File

@@ -0,0 +1,165 @@
/**
* Tests for credential scanning in the sleep cycle.
*
* Verifies that CREDENTIAL_PATTERNS and detectCredential() correctly
* identify credential-like content in memory text while not flagging
* clean text.
*/
import { describe, it, expect } from "vitest";
import { CREDENTIAL_PATTERNS, detectCredential } from "./sleep-cycle.js";
describe("Credential Detection", () => {
// --------------------------------------------------------------------------
// detectCredential() — should flag dangerous content
// --------------------------------------------------------------------------
describe("should detect credentials", () => {
it("detects API keys (sk-...)", () => {
const result = detectCredential("Use the key sk-abc123def456ghi789jkl012mno345");
expect(result).toBe("API key");
});
it("detects api_key patterns", () => {
const result = detectCredential("Set api_key_live_abcdef1234567890abcdef");
expect(result).toBe("API key");
});
it("detects Bearer tokens", () => {
const result = detectCredential(
"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
);
// Could match either Bearer token or JWT — both are valid detections
expect(result).not.toBeNull();
});
it("detects password assignments (password: X)", () => {
const result = detectCredential("The database password: myS3cretP@ss!");
expect(result).toBe("Password assignment");
});
it("detects password assignments (password=X)", () => {
const result = detectCredential("config has password=hunter2 in it");
expect(result).toBe("Password assignment");
});
it("detects the missed pattern: login with X creds user/pass", () => {
const result = detectCredential("login with radarr creds hullah/fuckbar");
expect(result).toBe("Credentials (user/pass)");
});
it("detects creds user/pass without login prefix", () => {
const result = detectCredential("use creds admin/password123 for the server");
expect(result).toBe("Credentials (user/pass)");
});
it("detects URL-embedded credentials", () => {
const result = detectCredential("Connect to https://admin:secretpass@db.example.com/mydb");
expect(result).toBe("URL credentials");
});
it("detects URL credentials with http://", () => {
const result = detectCredential("http://user:pass@192.168.1.1:8080/api");
expect(result).toBe("URL credentials");
});
it("detects private keys", () => {
const result = detectCredential("-----BEGIN RSA PRIVATE KEY-----\nMIIEow...");
expect(result).toBe("Private key");
});
it("detects AWS access keys", () => {
const result = detectCredential("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
expect(result).toBe("AWS key");
});
it("detects GitHub personal access tokens", () => {
const result = detectCredential("Set GITHUB_TOKEN=ghp_ABCDEFabcdef1234567890");
expect(result).toBe("GitHub/GitLab token");
});
it("detects GitLab tokens", () => {
const result = detectCredential("Use glpat-xxxxxxxxxxxxxxxxxxxx for auth");
expect(result).toBe("GitHub/GitLab token");
});
it("detects JWT tokens", () => {
const result = detectCredential(
"Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
);
expect(result).toBe("JWT");
});
it("detects token=value patterns", () => {
const result = detectCredential(
"Set token=abcdef1234567890abcdef1234567890ab for authentication",
);
expect(result).toBe("Token/secret");
});
it("detects secret: value patterns", () => {
const result = detectCredential(
"The client secret: abcdef1234567890abcdef1234567890abcdef12",
);
expect(result).toBe("Token/secret");
});
});
// --------------------------------------------------------------------------
// detectCredential() — should NOT flag clean text
// --------------------------------------------------------------------------
describe("should not flag clean text", () => {
it("does not flag normal text", () => {
expect(detectCredential("Remember to buy groceries tomorrow")).toBeNull();
});
it("does not flag password advice (without actual password)", () => {
expect(
detectCredential("Make sure the password is at least 8 characters long for security"),
).toBeNull();
});
it("does not flag discussion about tokens", () => {
expect(detectCredential("We should use JWT tokens for authentication")).toBeNull();
});
it("does not flag short key-like words", () => {
expect(detectCredential("The key to success is persistence")).toBeNull();
});
it("does not flag URLs without credentials", () => {
expect(detectCredential("Visit https://example.com/api/v1 for docs")).toBeNull();
});
it("does not flag discussion about API key rotation", () => {
expect(detectCredential("Rotate your API keys every 90 days as a best practice")).toBeNull();
});
it("does not flag file paths", () => {
expect(detectCredential("Credentials are stored in /home/user/.secrets/api.json")).toBeNull();
});
it("does not flag casual use of slash in text", () => {
expect(detectCredential("Use the read/write mode for better performance")).toBeNull();
});
});
// --------------------------------------------------------------------------
// CREDENTIAL_PATTERNS — structural checks
// --------------------------------------------------------------------------
describe("CREDENTIAL_PATTERNS structure", () => {
it("has at least 8 patterns", () => {
expect(CREDENTIAL_PATTERNS.length).toBeGreaterThanOrEqual(8);
});
it("each pattern has a label and valid RegExp", () => {
for (const { pattern, label } of CREDENTIAL_PATTERNS) {
expect(pattern).toBeInstanceOf(RegExp);
expect(label).toBeTruthy();
expect(typeof label).toBe("string");
}
});
});
});

View File

@@ -0,0 +1,234 @@
/**
* Tests for Phase 7: Task-Memory Cleanup in the sleep cycle.
*
* Tests the LLM classification function and integration with the sleep cycle.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ExtractionConfig } from "./config.js";
import { classifyTaskMemory } from "./sleep-cycle.js";
// --------------------------------------------------------------------------
// Mock the LLM client so we don't make real API calls
// --------------------------------------------------------------------------
vi.mock("./llm-client.js", () => ({
callOpenRouter: vi.fn(),
callOpenRouterStream: vi.fn(),
isTransientError: vi.fn(() => false),
}));
// Import the mocked function for controlling behavior per test
import { callOpenRouter } from "./llm-client.js";
const mockCallOpenRouter = vi.mocked(callOpenRouter);
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
const baseConfig: ExtractionConfig = {
enabled: true,
apiKey: "test-key",
model: "test-model",
baseUrl: "http://localhost:8080",
temperature: 0,
maxRetries: 0,
};
const disabledConfig: ExtractionConfig = {
...baseConfig,
enabled: false,
};
// --------------------------------------------------------------------------
// classifyTaskMemory()
// --------------------------------------------------------------------------
describe("classifyTaskMemory", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 'noise' for task-specific progress memory", async () => {
mockCallOpenRouter.mockResolvedValueOnce(
JSON.stringify({
classification: "noise",
reason: "This is task-specific progress tracking",
}),
);
const result = await classifyTaskMemory(
"Currently working on TASK-003, step 2: fixing the column alignment in the LinkedIn dashboard",
"Fix LinkedIn Dashboard tab",
baseConfig,
);
expect(result).toBe("noise");
expect(mockCallOpenRouter).toHaveBeenCalledOnce();
});
it("returns 'lasting' for decision/fact memory", async () => {
mockCallOpenRouter.mockResolvedValueOnce(
JSON.stringify({
classification: "lasting",
reason: "Contains a reusable technical decision",
}),
);
const result = await classifyTaskMemory(
"ReActor face swap produces better results than Replicate for video face replacement",
"Implement face swap pipeline",
baseConfig,
);
expect(result).toBe("lasting");
expect(mockCallOpenRouter).toHaveBeenCalledOnce();
});
it("returns 'lasting' when LLM returns null (conservative)", async () => {
mockCallOpenRouter.mockResolvedValueOnce(null);
const result = await classifyTaskMemory("Some ambiguous memory", "Some task", baseConfig);
expect(result).toBe("lasting");
});
it("returns 'lasting' when LLM throws (conservative)", async () => {
mockCallOpenRouter.mockRejectedValueOnce(new Error("network error"));
const result = await classifyTaskMemory("Some memory", "Some task", baseConfig);
expect(result).toBe("lasting");
});
it("returns 'lasting' when LLM returns malformed JSON", async () => {
mockCallOpenRouter.mockResolvedValueOnce("not json at all");
const result = await classifyTaskMemory("Some memory", "Some task", baseConfig);
expect(result).toBe("lasting");
});
it("returns 'lasting' when LLM returns unexpected classification", async () => {
mockCallOpenRouter.mockResolvedValueOnce(JSON.stringify({ classification: "unknown_value" }));
const result = await classifyTaskMemory("Some memory", "Some task", baseConfig);
expect(result).toBe("lasting");
});
it("returns 'lasting' when config is disabled", async () => {
const result = await classifyTaskMemory("Task progress memory", "Some task", disabledConfig);
expect(result).toBe("lasting");
expect(mockCallOpenRouter).not.toHaveBeenCalled();
});
it("passes task title in system prompt", async () => {
mockCallOpenRouter.mockResolvedValueOnce(JSON.stringify({ classification: "lasting" }));
await classifyTaskMemory("Memory text here", "Fix LinkedIn Dashboard tab", baseConfig);
expect(mockCallOpenRouter).toHaveBeenCalledOnce();
const callArgs = mockCallOpenRouter.mock.calls[0];
const messages = callArgs[1] as Array<{ role: string; content: string }>;
expect(messages[0].content).toContain("Fix LinkedIn Dashboard tab");
});
it("passes memory text as user message", async () => {
mockCallOpenRouter.mockResolvedValueOnce(JSON.stringify({ classification: "noise" }));
await classifyTaskMemory(
"Debugging step: checked column B3 alignment",
"Fix Dashboard",
baseConfig,
);
const callArgs = mockCallOpenRouter.mock.calls[0];
const messages = callArgs[1] as Array<{ role: string; content: string }>;
expect(messages[1].role).toBe("user");
expect(messages[1].content).toBe("Debugging step: checked column B3 alignment");
});
it("passes abort signal to LLM call", async () => {
const controller = new AbortController();
mockCallOpenRouter.mockResolvedValueOnce(JSON.stringify({ classification: "lasting" }));
await classifyTaskMemory("Memory text", "Task title", baseConfig, controller.signal);
const callArgs = mockCallOpenRouter.mock.calls[0];
expect(callArgs[2]).toBe(controller.signal);
});
});
// --------------------------------------------------------------------------
// Classification examples — verify the prompt produces expected behavior
// These test that noise vs lasting classification is passed through correctly
// --------------------------------------------------------------------------
describe("classifyTaskMemory classification examples", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const noiseExamples = [
{
memory: "Currently working on TASK-003, step 2: fixing the column alignment",
task: "Fix LinkedIn Dashboard tab",
reason: "task progress update",
},
{
memory: "ACTIVE TASK: TASK-004 — Fix browser port collision. Step: testing port 18807",
task: "Fix browser port collision",
reason: "active task checkpoint",
},
{
memory: "Debugging the flight search: Scoot API returned 500, retrying with different dates",
task: "Book KL↔Singapore flights for India trip",
reason: "debugging steps",
},
];
for (const example of noiseExamples) {
it(`classifies "${example.reason}" as noise`, async () => {
mockCallOpenRouter.mockResolvedValueOnce(
JSON.stringify({ classification: "noise", reason: example.reason }),
);
const result = await classifyTaskMemory(example.memory, example.task, baseConfig);
expect(result).toBe("noise");
});
}
const lastingExamples = [
{
memory:
"Port map: 18792 (chrome), 18800 (chetan), 18805 (linkedin), 18806 (tsukhani), 18807 (openclaw)",
task: "Fix browser port collision",
reason: "useful reference configuration",
},
{
memory:
"Dashboard layout: B3:B9 = Total, Accepted, Pending, Not Connected, Follow-ups Sent, Acceptance Rate%, Date",
task: "Fix LinkedIn Dashboard tab",
reason: "lasting documentation of layout",
},
{
memory: "ReActor face swap produces better results than Replicate for video face replacement",
task: "Implement face swap pipeline",
reason: "tool comparison decision",
},
];
for (const example of lastingExamples) {
it(`classifies "${example.reason}" as lasting`, async () => {
mockCallOpenRouter.mockResolvedValueOnce(
JSON.stringify({ classification: "lasting", reason: example.reason }),
);
const result = await classifyTaskMemory(example.memory, example.task, baseConfig);
expect(result).toBe("lasting");
});
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,426 @@
/**
* Tests for task-filter.ts — Task-aware recall filtering (Layer 1).
*
* Verifies that memories related to completed tasks are correctly identified
* and filtered, while unrelated or loosely-matching memories are preserved.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildCompletedTaskInfo,
clearTaskFilterCache,
extractSignificantKeywords,
isRelatedToCompletedTask,
loadCompletedTaskKeywords,
type CompletedTaskInfo,
} from "./task-filter.js";
// ============================================================================
// Sample TASKS.md content
// ============================================================================
const SAMPLE_TASKS_MD = `# Active Tasks
_No active tasks_
# Completed
<!-- Move done tasks here with completion date -->
## TASK-002: Book KL↔Singapore flights for India trip
- **Completed:** 2026-02-16
- **Details:** Tarun booked manually — Scoot TR453 (Feb 23 KUL→SIN) and AirAsia AK720 (Mar 3 SIN→KUL)
## TASK-003: Fix LinkedIn Dashboard tab
- **Completed:** 2026-02-16
- **Details:** Fixed misaligned stats, wrong industry numbers, stale data. Added Not Connected row, consolidated industries into 10 groups, cleared residual data.
## TASK-004: Fix browser port collision
- **Completed:** 2026-02-16
- **Details:** Added explicit openclaw profile on port 18807 (was colliding with chetan on 18800)
`;
// ============================================================================
// extractSignificantKeywords()
// ============================================================================
describe("extractSignificantKeywords", () => {
it("extracts words with length >= 4", () => {
const keywords = extractSignificantKeywords("Fix the big dashboard bug");
expect(keywords).toContain("dashboard");
expect(keywords).not.toContain("fix"); // too short
expect(keywords).not.toContain("the"); // too short
expect(keywords).not.toContain("big"); // too short
expect(keywords).not.toContain("bug"); // too short
});
it("removes stop words", () => {
const keywords = extractSignificantKeywords("should have been using this work");
// All of these are stop words
expect(keywords).toHaveLength(0);
});
it("lowercases all keywords", () => {
const keywords = extractSignificantKeywords("LinkedIn Dashboard Singapore");
expect(keywords).toContain("linkedin");
expect(keywords).toContain("dashboard");
expect(keywords).toContain("singapore");
});
it("deduplicates keywords", () => {
const keywords = extractSignificantKeywords("dashboard dashboard dashboard");
expect(keywords).toEqual(["dashboard"]);
});
it("returns empty for empty/null input", () => {
expect(extractSignificantKeywords("")).toEqual([]);
expect(extractSignificantKeywords(null as unknown as string)).toEqual([]);
});
it("handles special characters", () => {
const keywords = extractSignificantKeywords("port 18807 (colliding with chetan)");
expect(keywords).toContain("port");
expect(keywords).toContain("18807");
expect(keywords).toContain("colliding");
expect(keywords).toContain("chetan");
});
});
// ============================================================================
// buildCompletedTaskInfo()
// ============================================================================
describe("buildCompletedTaskInfo", () => {
it("extracts keywords from title and details", () => {
const info = buildCompletedTaskInfo({
id: "TASK-003",
title: "Fix LinkedIn Dashboard tab",
status: "done",
details:
"Fixed misaligned stats, wrong industry numbers, stale data. Added Not Connected row, consolidated industries into 10 groups, cleared residual data.",
rawLines: [
"## TASK-003: Fix LinkedIn Dashboard tab",
"- **Completed:** 2026-02-16",
"- **Details:** Fixed misaligned stats, wrong industry numbers, stale data.",
],
isCompleted: true,
});
expect(info.id).toBe("TASK-003");
expect(info.keywords).toContain("linkedin");
expect(info.keywords).toContain("dashboard");
expect(info.keywords).toContain("misaligned");
expect(info.keywords).toContain("stats");
expect(info.keywords).toContain("industry");
});
it("includes currentStep keywords", () => {
const info = buildCompletedTaskInfo({
id: "TASK-010",
title: "Deploy staging server",
status: "done",
currentStep: "Verifying nginx configuration",
rawLines: ["## TASK-010: Deploy staging server"],
isCompleted: true,
});
expect(info.keywords).toContain("deploy");
expect(info.keywords).toContain("staging");
expect(info.keywords).toContain("server");
expect(info.keywords).toContain("nginx");
expect(info.keywords).toContain("configuration");
});
it("handles task with minimal fields", () => {
const info = buildCompletedTaskInfo({
id: "TASK-001",
title: "Quick fix",
status: "done",
rawLines: ["## TASK-001: Quick fix"],
isCompleted: true,
});
expect(info.id).toBe("TASK-001");
expect(info.keywords).toContain("quick");
// "fix" is only 3 chars, should be excluded
expect(info.keywords).not.toContain("fix");
});
});
// ============================================================================
// isRelatedToCompletedTask()
// ============================================================================
describe("isRelatedToCompletedTask", () => {
const completedTasks: CompletedTaskInfo[] = [
{
id: "TASK-002",
keywords: [
"book",
"singapore",
"flights",
"india",
"trip",
"scoot",
"tr453",
"airasia",
"ak720",
],
},
{
id: "TASK-003",
keywords: [
"linkedin",
"dashboard",
"misaligned",
"stats",
"industry",
"numbers",
"stale",
"connected",
"consolidated",
"industries",
"groups",
"cleared",
"residual",
"data",
],
},
{
id: "TASK-004",
keywords: [
"browser",
"port",
"collision",
"openclaw",
"profile",
"18807",
"colliding",
"chetan",
"18800",
],
},
];
// --- Task ID matching ---
it("matches memory containing task ID", () => {
expect(
isRelatedToCompletedTask("TASK-002 flights have been booked successfully", completedTasks),
).toBe(true);
});
it("matches task ID case-insensitively", () => {
expect(
isRelatedToCompletedTask("Completed task-003 — dashboard is fixed", completedTasks),
).toBe(true);
});
// --- Keyword matching ---
it("matches memory with 2+ keywords from a completed task", () => {
expect(
isRelatedToCompletedTask(
"LinkedIn dashboard stats are now showing correctly",
completedTasks,
),
).toBe(true);
});
it("matches memory with keywords from flight task", () => {
expect(
isRelatedToCompletedTask("Booked Singapore flights for the India trip", completedTasks),
).toBe(true);
});
// --- False positive prevention ---
it("does NOT match memory with only 1 keyword overlap", () => {
expect(isRelatedToCompletedTask("Singapore has great food markets", completedTasks)).toBe(
false,
);
});
it("does NOT match memory about LinkedIn that is unrelated to dashboard fix", () => {
// "linkedin" alone is only 1 keyword match — should NOT be filtered
expect(
isRelatedToCompletedTask(
"LinkedIn connection request from John Smith accepted",
completedTasks,
),
).toBe(false);
});
it("does NOT match memory about browser that is unrelated to port fix", () => {
// "browser" alone is only 1 keyword
expect(
isRelatedToCompletedTask("Browser extension for Flux image generation", completedTasks),
).toBe(false);
});
it("does NOT match completely unrelated memory", () => {
expect(isRelatedToCompletedTask("Tarun's birthday is August 23, 1974", completedTasks)).toBe(
false,
);
});
// --- Edge cases ---
it("returns false for empty memory text", () => {
expect(isRelatedToCompletedTask("", completedTasks)).toBe(false);
});
it("returns false for empty completed tasks array", () => {
expect(isRelatedToCompletedTask("TASK-002 flights booked", [])).toBe(false);
});
it("handles task with no keywords (only ID matching works)", () => {
const tasksNoKeywords: CompletedTaskInfo[] = [{ id: "TASK-099", keywords: [] }];
expect(isRelatedToCompletedTask("Completed TASK-099", tasksNoKeywords)).toBe(true);
expect(isRelatedToCompletedTask("Some random memory", tasksNoKeywords)).toBe(false);
});
});
// ============================================================================
// loadCompletedTaskKeywords()
// ============================================================================
describe("loadCompletedTaskKeywords", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-filter-test-"));
clearTaskFilterCache();
});
afterEach(async () => {
clearTaskFilterCache();
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("parses completed tasks from TASKS.md", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), SAMPLE_TASKS_MD);
const tasks = await loadCompletedTaskKeywords(tmpDir);
expect(tasks).toHaveLength(3);
expect(tasks.map((t) => t.id)).toEqual(["TASK-002", "TASK-003", "TASK-004"]);
});
it("extracts keywords from completed tasks", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), SAMPLE_TASKS_MD);
const tasks = await loadCompletedTaskKeywords(tmpDir);
const flightTask = tasks.find((t) => t.id === "TASK-002");
expect(flightTask).toBeDefined();
expect(flightTask!.keywords).toContain("singapore");
expect(flightTask!.keywords).toContain("flights");
});
it("returns empty array when TASKS.md does not exist", async () => {
const tasks = await loadCompletedTaskKeywords(tmpDir);
expect(tasks).toEqual([]);
});
it("returns empty array for empty TASKS.md", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), "");
const tasks = await loadCompletedTaskKeywords(tmpDir);
expect(tasks).toEqual([]);
});
it("returns empty array for TASKS.md with no completed tasks", async () => {
const content = `# Active Tasks
## TASK-001: Do something
- **Status:** in_progress
- **Details:** Working on it
# Completed
<!-- Move done tasks here with completion date -->
`;
await fs.writeFile(path.join(tmpDir, "TASKS.md"), content);
const tasks = await loadCompletedTaskKeywords(tmpDir);
expect(tasks).toEqual([]);
});
it("handles malformed TASKS.md gracefully", async () => {
const content = `This is not a valid TASKS.md file
Just some random text
No headers or structure at all`;
await fs.writeFile(path.join(tmpDir, "TASKS.md"), content);
const tasks = await loadCompletedTaskKeywords(tmpDir);
expect(tasks).toEqual([]);
});
// --- Cache behavior ---
it("returns cached data within TTL", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), SAMPLE_TASKS_MD);
const first = await loadCompletedTaskKeywords(tmpDir);
expect(first).toHaveLength(3);
// Modify the file — should still return cached result
await fs.writeFile(path.join(tmpDir, "TASKS.md"), "# Active Tasks\n\n# Completed\n");
const second = await loadCompletedTaskKeywords(tmpDir);
expect(second).toHaveLength(3); // Still cached
expect(second).toBe(first); // Same reference (from cache)
});
it("refreshes after cache is cleared", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), SAMPLE_TASKS_MD);
const first = await loadCompletedTaskKeywords(tmpDir);
expect(first).toHaveLength(3);
// Modify file and clear cache
await fs.writeFile(path.join(tmpDir, "TASKS.md"), "# Active Tasks\n\n# Completed\n");
clearTaskFilterCache();
const second = await loadCompletedTaskKeywords(tmpDir);
expect(second).toHaveLength(0); // Re-read from disk
});
});
// ============================================================================
// Integration: end-to-end filtering
// ============================================================================
describe("end-to-end recall filtering", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-filter-e2e-"));
clearTaskFilterCache();
});
afterEach(async () => {
clearTaskFilterCache();
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("filters memories related to completed tasks while keeping unrelated ones", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), SAMPLE_TASKS_MD);
const completedTasks = await loadCompletedTaskKeywords(tmpDir);
const memories = [
{ text: "TASK-002 flights have been booked — Scoot TR453 confirmed", keep: false },
{ text: "LinkedIn dashboard stats fixed — industry numbers corrected", keep: false },
{ text: "Browser port collision resolved — openclaw on 18807", keep: false },
{ text: "Tarun's birthday is August 23, 1974", keep: true },
{ text: "Singapore has great food markets", keep: true },
{ text: "LinkedIn connection from Jane Doe accepted", keep: true },
{ text: "Memory-neo4j sleep cycle runs at 3am", keep: true },
];
for (const m of memories) {
const isRelated = isRelatedToCompletedTask(m.text, completedTasks);
expect(isRelated).toBe(!m.keep);
}
});
});

View File

@@ -0,0 +1,324 @@
/**
* Task-aware recall filter (Layer 1).
*
* Filters out auto-recalled memories that relate to completed tasks,
* preventing stale task-state memories from being injected into agent context.
*
* Design principles:
* - Conservative: false positives (filtering useful memories) are worse than
* false negatives (letting some stale ones through).
* - Fast: runs on every message, targeting < 5ms with caching.
* - Graceful: missing/malformed TASKS.md is silently ignored.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { parseTaskLedger, type ParsedTask } from "./task-ledger.js";
// ============================================================================
// Types
// ============================================================================
/** Extracted keyword info for a single completed task. */
export type CompletedTaskInfo = {
/** Task ID (e.g. "TASK-002") */
id: string;
/** Significant keywords extracted from the task title + details + currentStep */
keywords: string[];
};
// ============================================================================
// Constants
// ============================================================================
/** Cache TTL in milliseconds — avoids re-reading TASKS.md on every message. */
const CACHE_TTL_MS = 60_000;
/** Minimum keyword length to be considered "significant". */
const MIN_KEYWORD_LENGTH = 4;
/**
* Common English stop words that should be excluded from keyword matching.
* Only words ≥ MIN_KEYWORD_LENGTH are included (shorter ones are filtered by length).
*/
const STOP_WORDS = new Set([
"about",
"also",
"been",
"before",
"being",
"between",
"both",
"came",
"come",
"could",
"does",
"done",
"each",
"even",
"find",
"first",
"found",
"from",
"going",
"good",
"great",
"have",
"here",
"high",
"however",
"into",
"just",
"keep",
"know",
"last",
"like",
"long",
"look",
"made",
"make",
"many",
"more",
"most",
"much",
"must",
"need",
"next",
"only",
"other",
"over",
"part",
"said",
"same",
"should",
"show",
"since",
"some",
"still",
"such",
"take",
"than",
"that",
"their",
"them",
"then",
"there",
"these",
"they",
"this",
"through",
"time",
"under",
"used",
"using",
"very",
"want",
"were",
"what",
"when",
"where",
"which",
"while",
"will",
"with",
"without",
"work",
"would",
"your",
// Task-related generic words that shouldn't be matching keywords:
"task",
"tasks",
"active",
"completed",
"details",
"status",
"started",
"updated",
"blocked",
]);
/**
* Minimum number of keyword matches required to consider a memory related
* to a completed task (when matching by keywords rather than task ID).
*/
const MIN_KEYWORD_MATCHES = 2;
// ============================================================================
// Cache
// ============================================================================
type CacheEntry = {
tasks: CompletedTaskInfo[];
timestamp: number;
};
const cache = new Map<string, CacheEntry>();
/** Clear the cache (exposed for testing). */
export function clearTaskFilterCache(): void {
cache.clear();
}
// ============================================================================
// Keyword Extraction
// ============================================================================
/**
* Extract significant keywords from a text string.
*
* Filters out short words, stop words, and common noise to produce
* a set of meaningful terms that can identify task-specific content.
*/
export function extractSignificantKeywords(text: string): string[] {
if (!text) {
return [];
}
const words = text
.toLowerCase()
// Replace non-alphanumeric chars (except hyphens in task IDs) with spaces
.replace(/[^a-z0-9\-]/g, " ")
.split(/\s+/)
.filter((w) => w.length >= MIN_KEYWORD_LENGTH && !STOP_WORDS.has(w));
// Deduplicate while preserving order
return [...new Set(words)];
}
/**
* Build a {@link CompletedTaskInfo} from a parsed completed task.
*
* Extracts keywords from the task's title, details, and current step.
*/
export function buildCompletedTaskInfo(task: ParsedTask): CompletedTaskInfo {
const parts: string[] = [task.title];
if (task.details) {
parts.push(task.details);
}
if (task.currentStep) {
parts.push(task.currentStep);
}
// Also extract from raw lines to capture fields the parser doesn't map
// (e.g. "- **Completed:** 2026-02-16")
for (const line of task.rawLines) {
const trimmed = line.trim();
// Skip the header line (already have title) and empty lines
if (trimmed.startsWith("##") || trimmed === "") {
continue;
}
// Include field values from bullet lines
const fieldMatch = trimmed.match(/^-\s+\*\*.+?:\*\*\s*(.+)$/);
if (fieldMatch) {
parts.push(fieldMatch[1]);
}
}
const keywords = extractSignificantKeywords(parts.join(" "));
return {
id: task.id,
keywords,
};
}
// ============================================================================
// Core API
// ============================================================================
/**
* Load completed task info from TASKS.md in the given workspace directory.
*
* Results are cached per workspace dir with a 60-second TTL to avoid
* re-reading and re-parsing on every message.
*
* @param workspaceDir - Path to the workspace directory containing TASKS.md
* @returns Array of completed task info (empty if TASKS.md is missing or has no completed tasks)
*/
export async function loadCompletedTaskKeywords(
workspaceDir: string,
): Promise<CompletedTaskInfo[]> {
const now = Date.now();
// Check cache
const cached = cache.get(workspaceDir);
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
return cached.tasks;
}
// Read and parse TASKS.md
const tasksPath = path.join(workspaceDir, "TASKS.md");
let content: string;
try {
content = await fs.readFile(tasksPath, "utf-8");
} catch {
// File doesn't exist or isn't readable — cache empty result
cache.set(workspaceDir, { tasks: [], timestamp: now });
return [];
}
if (!content.trim()) {
cache.set(workspaceDir, { tasks: [], timestamp: now });
return [];
}
const ledger = parseTaskLedger(content);
const tasks = ledger.completedTasks.map(buildCompletedTaskInfo);
// Cache the result
cache.set(workspaceDir, { tasks, timestamp: now });
return tasks;
}
/**
* Check if a memory's text is related to a completed task.
*
* Uses two matching strategies:
* 1. **Task ID match** — if the memory text contains a completed task's ID
* (e.g. "TASK-002"), it's considered related.
* 2. **Keyword match** — if the memory text matches {@link MIN_KEYWORD_MATCHES}
* or more significant keywords from a completed task, it's considered related.
*
* The filter is intentionally conservative: a memory about "Flux 2" won't be
* filtered just because a completed task mentioned "Flux", unless the memory
* also matches additional task-specific keywords.
*
* @param memoryText - The text content of the recalled memory
* @param completedTasks - Completed task info from {@link loadCompletedTaskKeywords}
* @returns `true` if the memory appears related to a completed task
*/
export function isRelatedToCompletedTask(
memoryText: string,
completedTasks: CompletedTaskInfo[],
): boolean {
if (!memoryText || completedTasks.length === 0) {
return false;
}
const lowerText = memoryText.toLowerCase();
for (const task of completedTasks) {
// Strategy 1: Direct task ID match (case-insensitive)
if (lowerText.includes(task.id.toLowerCase())) {
return true;
}
// Strategy 2: Keyword overlap — require MIN_KEYWORD_MATCHES distinct keywords
if (task.keywords.length === 0) {
continue;
}
let matchCount = 0;
for (const keyword of task.keywords) {
if (lowerText.includes(keyword)) {
matchCount++;
if (matchCount >= MIN_KEYWORD_MATCHES) {
return true;
}
}
}
}
return false;
}

View File

@@ -0,0 +1,466 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
findStaleTasks,
parseTaskDate,
parseTaskLedger,
reviewAndArchiveStaleTasks,
serializeTask,
serializeTaskLedger,
} from "./task-ledger.js";
// ============================================================================
// parseTaskDate
// ============================================================================
describe("parseTaskDate", () => {
it("parses YYYY-MM-DD HH:MM format", () => {
const date = parseTaskDate("2026-02-14 09:15");
expect(date).not.toBeNull();
expect(date!.getFullYear()).toBe(2026);
expect(date!.getMonth()).toBe(1); // February is month 1
expect(date!.getDate()).toBe(14);
});
it("parses YYYY-MM-DD HH:MM with timezone abbreviation", () => {
const date = parseTaskDate("2026-02-14 09:15 MYT");
expect(date).not.toBeNull();
expect(date!.getFullYear()).toBe(2026);
});
it("parses ISO format", () => {
const date = parseTaskDate("2026-02-14T09:15:00");
expect(date).not.toBeNull();
expect(date!.getFullYear()).toBe(2026);
});
it("returns null for empty string", () => {
expect(parseTaskDate("")).toBeNull();
});
it("returns null for invalid date", () => {
expect(parseTaskDate("not-a-date")).toBeNull();
});
});
// ============================================================================
// parseTaskLedger
// ============================================================================
describe("parseTaskLedger", () => {
it("parses a simple task ledger", () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Restaurant Booking",
"- **Status:** in_progress",
"- **Started:** 2026-02-14 09:15",
"- **Updated:** 2026-02-14 09:30",
"- **Details:** Graze, 4 pax, 19:30",
"- **Current Step:** Form filled, awaiting confirmation",
"",
"# Completed",
"<!-- Move done tasks here with completion date -->",
].join("\n");
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(1);
expect(ledger.completedTasks).toHaveLength(0);
const task = ledger.activeTasks[0];
expect(task.id).toBe("TASK-001");
expect(task.title).toBe("Restaurant Booking");
expect(task.status).toBe("in_progress");
expect(task.started).toBe("2026-02-14 09:15");
expect(task.updated).toBe("2026-02-14 09:30");
expect(task.details).toBe("Graze, 4 pax, 19:30");
expect(task.currentStep).toBe("Form filled, awaiting confirmation");
expect(task.isCompleted).toBe(false);
});
it("parses multiple active tasks", () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Task One",
"- **Status:** in_progress",
"- **Started:** 2026-02-14 09:00",
"",
"## TASK-002: Task Two",
"- **Status:** awaiting_input",
"- **Started:** 2026-02-14 10:00",
"",
"# Completed",
].join("\n");
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(2);
expect(ledger.activeTasks[0].id).toBe("TASK-001");
expect(ledger.activeTasks[1].id).toBe("TASK-002");
});
it("parses completed tasks", () => {
const content = [
"# Active Tasks",
"",
"# Completed",
"",
"## ~~TASK-001: Old Task~~",
"- **Status:** done",
"- **Started:** 2026-02-13 09:00",
"- **Updated:** 2026-02-13 15:00",
].join("\n");
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(0);
expect(ledger.completedTasks).toHaveLength(1);
expect(ledger.completedTasks[0].id).toBe("TASK-001");
expect(ledger.completedTasks[0].isCompleted).toBe(true);
});
it("parses blocked tasks", () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Blocked Task",
"- **Status:** blocked",
"- **Started:** 2026-02-14 09:00",
"- **Blocked On:** Waiting for API key",
"",
"# Completed",
].join("\n");
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(1);
expect(ledger.activeTasks[0].blockedOn).toBe("Waiting for API key");
});
it("handles empty task ledger", () => {
const content = [
"# Active Tasks",
"",
"# Completed",
"<!-- Move done tasks here with completion date -->",
].join("\n");
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(0);
expect(ledger.completedTasks).toHaveLength(0);
});
it("handles Last Updated field variant", () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Some Task",
"- **Status:** in_progress",
"- **Last Updated:** 2026-02-14 10:00",
"",
"# Completed",
].join("\n");
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks[0].updated).toBe("2026-02-14 10:00");
});
});
// ============================================================================
// findStaleTasks
// ============================================================================
describe("findStaleTasks", () => {
const now = new Date("2026-02-15T10:00:00");
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
it("identifies tasks older than 24h as stale", () => {
const tasks = [
{
id: "TASK-001",
title: "Old Task",
status: "in_progress" as const,
updated: "2026-02-14 08:00",
rawLines: [],
isCompleted: false,
},
];
const stale = findStaleTasks(tasks, now, twentyFourHoursMs);
expect(stale).toHaveLength(1);
expect(stale[0].id).toBe("TASK-001");
});
it("does not mark recent tasks as stale", () => {
const tasks = [
{
id: "TASK-001",
title: "Recent Task",
status: "in_progress" as const,
updated: "2026-02-15 09:00",
rawLines: [],
isCompleted: false,
},
];
const stale = findStaleTasks(tasks, now, twentyFourHoursMs);
expect(stale).toHaveLength(0);
});
it("skips done tasks", () => {
const tasks = [
{
id: "TASK-001",
title: "Done Task",
status: "done" as const,
updated: "2026-02-13 08:00",
rawLines: [],
isCompleted: false,
},
];
const stale = findStaleTasks(tasks, now, twentyFourHoursMs);
expect(stale).toHaveLength(0);
});
it("skips already-stale tasks", () => {
const tasks = [
{
id: "TASK-001",
title: "Already Stale",
status: "stale" as const,
updated: "2026-02-13 08:00",
rawLines: [],
isCompleted: false,
},
];
const stale = findStaleTasks(tasks, now, twentyFourHoursMs);
expect(stale).toHaveLength(0);
});
it("uses started date when updated is missing", () => {
const tasks = [
{
id: "TASK-001",
title: "No Update Date",
status: "in_progress" as const,
started: "2026-02-14 08:00",
rawLines: [],
isCompleted: false,
},
];
const stale = findStaleTasks(tasks, now, twentyFourHoursMs);
expect(stale).toHaveLength(1);
});
it("marks tasks with no dates as stale", () => {
const tasks = [
{
id: "TASK-001",
title: "No Dates",
status: "in_progress" as const,
rawLines: [],
isCompleted: false,
},
];
const stale = findStaleTasks(tasks, now, twentyFourHoursMs);
expect(stale).toHaveLength(1);
});
});
// ============================================================================
// serializeTask / serializeTaskLedger
// ============================================================================
describe("serializeTask", () => {
it("serializes an active task", () => {
const task = {
id: "TASK-001",
title: "My Task",
status: "in_progress" as const,
started: "2026-02-14 09:00",
updated: "2026-02-14 10:00",
details: "Some details",
currentStep: "Step 1",
rawLines: [],
isCompleted: false,
};
const lines = serializeTask(task);
expect(lines[0]).toBe("## TASK-001: My Task");
expect(lines).toContain("- **Status:** in_progress");
expect(lines).toContain("- **Started:** 2026-02-14 09:00");
expect(lines).toContain("- **Updated:** 2026-02-14 10:00");
expect(lines).toContain("- **Details:** Some details");
expect(lines).toContain("- **Current Step:** Step 1");
});
it("serializes a completed task with strikethrough", () => {
const task = {
id: "TASK-001",
title: "Done Task",
status: "done" as const,
started: "2026-02-14 09:00",
rawLines: [],
isCompleted: true,
};
const lines = serializeTask(task);
expect(lines[0]).toBe("## ~~TASK-001: Done Task~~");
});
});
describe("serializeTaskLedger", () => {
it("round-trips a task ledger", () => {
const ledger = {
activeTasks: [
{
id: "TASK-001",
title: "Active Task",
status: "in_progress" as const,
started: "2026-02-14 09:00",
updated: "2026-02-14 10:00",
details: "Details here",
rawLines: [],
isCompleted: false,
},
],
completedTasks: [
{
id: "TASK-000",
title: "Old Task",
status: "done" as const,
started: "2026-02-13 09:00",
rawLines: [],
isCompleted: true,
},
],
preamble: [],
sectionSeparator: [],
postamble: [],
};
const serialized = serializeTaskLedger(ledger);
expect(serialized).toContain("# Active Tasks");
expect(serialized).toContain("## TASK-001: Active Task");
expect(serialized).toContain("# Completed");
expect(serialized).toContain("## ~~TASK-000: Old Task~~");
});
});
// ============================================================================
// reviewAndArchiveStaleTasks (integration with filesystem)
// ============================================================================
describe("reviewAndArchiveStaleTasks", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-ledger-test-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("returns null when TASKS.md does not exist", async () => {
const result = await reviewAndArchiveStaleTasks(tmpDir);
expect(result).toBeNull();
});
it("returns null for empty TASKS.md", async () => {
await fs.writeFile(path.join(tmpDir, "TASKS.md"), "", "utf-8");
const result = await reviewAndArchiveStaleTasks(tmpDir);
expect(result).toBeNull();
});
it("archives stale tasks", async () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Stale Task",
"- **Status:** in_progress",
"- **Started:** 2026-02-13 08:00",
"- **Updated:** 2026-02-13 09:00",
"",
"## TASK-002: Fresh Task",
"- **Status:** in_progress",
"- **Started:** 2026-02-14 09:00",
"- **Updated:** 2026-02-14 23:00",
"",
"# Completed",
"<!-- Move done tasks here with completion date -->",
].join("\n");
await fs.writeFile(path.join(tmpDir, "TASKS.md"), content, "utf-8");
// "now" is Feb 15, 10:00 — TASK-001 updated Feb 13, 09:00 (>24h ago), TASK-002 updated Feb 14, 23:00 (<24h ago)
const now = new Date("2026-02-15T10:00:00");
const result = await reviewAndArchiveStaleTasks(tmpDir, undefined, now);
expect(result).not.toBeNull();
expect(result!.staleCount).toBe(1);
expect(result!.archivedCount).toBe(1);
expect(result!.archivedIds).toEqual(["TASK-001"]);
// Verify the file was updated
const updated = await fs.readFile(path.join(tmpDir, "TASKS.md"), "utf-8");
expect(updated).toContain("## TASK-002: Fresh Task");
expect(updated).toContain("## ~~TASK-001: Stale Task~~");
// Re-parse to verify structure
const ledger = parseTaskLedger(updated);
expect(ledger.activeTasks).toHaveLength(1);
expect(ledger.activeTasks[0].id).toBe("TASK-002");
expect(ledger.completedTasks).toHaveLength(1);
expect(ledger.completedTasks[0].id).toBe("TASK-001");
expect(ledger.completedTasks[0].status).toBe("stale");
});
it("does nothing when no tasks are stale", async () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Fresh Task",
"- **Status:** in_progress",
"- **Started:** 2026-02-15 09:00",
"- **Updated:** 2026-02-15 09:30",
"",
"# Completed",
].join("\n");
await fs.writeFile(path.join(tmpDir, "TASKS.md"), content, "utf-8");
const now = new Date("2026-02-15T10:00:00");
const result = await reviewAndArchiveStaleTasks(tmpDir, undefined, now);
expect(result).not.toBeNull();
expect(result!.staleCount).toBe(0);
expect(result!.archivedCount).toBe(0);
});
it("supports custom maxAgeMs", async () => {
const content = [
"# Active Tasks",
"",
"## TASK-001: Semi-Fresh Task",
"- **Status:** in_progress",
"- **Started:** 2026-02-15 06:00",
"- **Updated:** 2026-02-15 06:00",
"",
"# Completed",
].join("\n");
await fs.writeFile(path.join(tmpDir, "TASKS.md"), content, "utf-8");
const now = new Date("2026-02-15T10:00:00");
const oneHourMs = 60 * 60 * 1000;
// With 1-hour threshold, task is stale (4 hours old)
const result = await reviewAndArchiveStaleTasks(tmpDir, oneHourMs, now);
expect(result!.archivedCount).toBe(1);
});
});

View File

@@ -0,0 +1,424 @@
/**
* Task Ledger (TASKS.md) maintenance utilities.
*
* Parses and updates the structured task ledger file used by agents
* to track active work across compaction events. The sleep cycle uses
* these utilities to archive stale tasks (>24h with no activity).
*/
import fs from "node:fs/promises";
import path from "node:path";
// ============================================================================
// Types
// ============================================================================
export type TaskStatus = "in_progress" | "awaiting_input" | "blocked" | "done" | "stale" | string;
export type ParsedTask = {
/** Task ID (e.g. "TASK-001") */
id: string;
/** Short title */
title: string;
/** Current status */
status: TaskStatus;
/** When the task was started (ISO-ish string) */
started?: string;
/** When the task was last updated (ISO-ish string) */
updated?: string;
/** Task details/description */
details?: string;
/** Current step being worked on */
currentStep?: string;
/** What's blocking progress */
blockedOn?: string;
/** Raw markdown lines for this task section (for round-tripping) */
rawLines: string[];
/** Whether this task is in the completed section */
isCompleted: boolean;
};
export type TaskLedger = {
activeTasks: ParsedTask[];
completedTasks: ParsedTask[];
/** Lines before the first task section (header, etc.) */
preamble: string[];
/** Lines between active and completed sections */
sectionSeparator: string[];
/** Lines after the completed section */
postamble: string[];
};
export type StaleTaskResult = {
/** Number of tasks found that are stale */
staleCount: number;
/** Number of tasks archived (moved to completed) */
archivedCount: number;
/** Task IDs that were archived */
archivedIds: string[];
};
// ============================================================================
// Parsing
// ============================================================================
/**
* Parse a TASKS.md file content into structured task data.
*/
export function parseTaskLedger(content: string): TaskLedger {
const lines = content.split("\n");
const activeTasks: ParsedTask[] = [];
const completedTasks: ParsedTask[] = [];
const preamble: string[] = [];
const sectionSeparator: string[] = [];
const postamble: string[] = [];
let currentSection: "preamble" | "active" | "completed" | "postamble" = "preamble";
let currentTask: ParsedTask | null = null;
for (const line of lines) {
const trimmed = line.trim();
// Detect section headers
if (/^#\s+Active\s+Tasks/i.test(trimmed)) {
if (currentTask) {
pushTask(currentTask, activeTasks, completedTasks);
currentTask = null;
}
currentSection = "active";
preamble.push(line);
continue;
}
if (/^#\s+Completed/i.test(trimmed)) {
if (currentTask) {
pushTask(currentTask, activeTasks, completedTasks);
currentTask = null;
}
currentSection = "completed";
sectionSeparator.push(line);
continue;
}
// Detect task headers (## TASK-NNN: Title or ## ~~TASK-NNN: Title~~)
const taskMatch = trimmed.match(/^##\s+(?:~~)?(TASK-\d+):\s*(.+?)(?:~~)?$/);
if (taskMatch) {
if (currentTask) {
pushTask(currentTask, activeTasks, completedTasks);
}
const isStrikethrough = trimmed.includes("~~");
currentTask = {
id: taskMatch[1],
title: taskMatch[2].replace(/~~/g, "").trim(),
status: isStrikethrough ? "done" : "in_progress",
rawLines: [line],
isCompleted: currentSection === "completed" || isStrikethrough,
};
continue;
}
// Parse task fields (- **Field:** Value)
if (currentTask) {
const fieldMatch = trimmed.match(/^-\s+\*\*(.+?):\*\*\s*(.*)$/);
if (fieldMatch) {
const fieldName = fieldMatch[1].toLowerCase();
const value = fieldMatch[2].trim();
switch (fieldName) {
case "status":
currentTask.status = value;
break;
case "started":
currentTask.started = value;
break;
case "updated":
case "last updated":
currentTask.updated = value;
break;
case "details":
currentTask.details = value;
break;
case "current step":
currentTask.currentStep = value;
break;
case "blocked on":
currentTask.blockedOn = value;
break;
}
currentTask.rawLines.push(line);
continue;
}
// Non-field lines within a task
if (trimmed !== "" && !trimmed.startsWith("#")) {
currentTask.rawLines.push(line);
continue;
}
// Empty line within a task — include it
if (trimmed === "") {
currentTask.rawLines.push(line);
continue;
}
}
if (
currentSection === "completed" &&
trimmed.startsWith("#") &&
!/^#\s+Completed/i.test(trimmed)
) {
currentSection = "postamble";
}
// Lines not part of a task
switch (currentSection) {
case "preamble":
case "active":
preamble.push(line);
break;
case "completed":
sectionSeparator.push(line);
break;
case "postamble":
postamble.push(line);
break;
}
}
// Push the last task
if (currentTask) {
pushTask(currentTask, activeTasks, completedTasks);
}
return { activeTasks, completedTasks, preamble, sectionSeparator, postamble };
}
function pushTask(task: ParsedTask, active: ParsedTask[], completed: ParsedTask[]) {
if (task.isCompleted || task.status === "done") {
completed.push(task);
} else {
active.push(task);
}
}
// ============================================================================
// Staleness Detection
// ============================================================================
/**
* Parse a date string from the task ledger.
* Accepts formats like "2026-02-14 09:15", "2026-02-14 09:15 MYT",
* "2026-02-14T09:15:00", etc.
*/
export function parseTaskDate(dateStr: string): Date | null {
if (!dateStr) {
return null;
}
const cleaned = dateStr
.trim()
// Remove timezone abbreviations like MYT, UTC, PST
.replace(/\s+[A-Z]{2,5}$/, "")
// Normalize space-separated date time to ISO
.replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})/, "$1T$2");
const date = new Date(cleaned);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
/**
* Find tasks that are stale (no update in more than `maxAgeMs` milliseconds).
* Default: 24 hours.
*/
export function findStaleTasks(
tasks: ParsedTask[],
now: Date = new Date(),
maxAgeMs: number = 24 * 60 * 60 * 1000,
): ParsedTask[] {
return tasks.filter((task) => {
// Only check active tasks (not already done/stale)
if (task.status === "done" || task.status === "stale") {
return false;
}
const lastUpdate = task.updated || task.started;
if (!lastUpdate) {
// No date info — consider stale if we can't determine age
return true;
}
const date = parseTaskDate(lastUpdate);
if (!date) {
return false; // Can't parse date — don't mark as stale
}
const ageMs = now.getTime() - date.getTime();
return ageMs > maxAgeMs;
});
}
// ============================================================================
// Task Ledger Serialization
// ============================================================================
/**
* Serialize a task back to markdown lines.
* If the task has rawLines from parsing, regenerate only the header and status
* (which may have changed) while preserving other raw content.
* For new/modified tasks without rawLines, generate from parsed fields.
*/
export function serializeTask(task: ParsedTask): string[] {
const titlePrefix = task.isCompleted
? `## ~~${task.id}: ${task.title}~~`
: `## ${task.id}: ${task.title}`;
// If we have rawLines and the task was only modified (status/updated changed
// by archival), rebuild from rawLines with updated field values.
if (task.rawLines.length > 0) {
const lines: string[] = [titlePrefix];
for (const line of task.rawLines.slice(1)) {
const trimmed = line.trim();
// Replace Status field with current value
if (/^-\s+\*\*Status:\*\*/.test(trimmed)) {
lines.push(`- **Status:** ${task.status}`);
} else if (/^-\s+\*\*(?:Updated|Last Updated):\*\*/.test(trimmed)) {
lines.push(`- **Updated:** ${task.updated ?? ""}`);
} else {
lines.push(line);
}
}
return lines;
}
// Fallback: generate from parsed fields (for newly created tasks)
const lines: string[] = [titlePrefix];
lines.push(`- **Status:** ${task.status}`);
if (task.started) {
lines.push(`- **Started:** ${task.started}`);
}
if (task.updated) {
lines.push(`- **Updated:** ${task.updated}`);
}
if (task.details) {
lines.push(`- **Details:** ${task.details}`);
}
if (task.currentStep) {
lines.push(`- **Current Step:** ${task.currentStep}`);
}
if (task.blockedOn) {
lines.push(`- **Blocked On:** ${task.blockedOn}`);
}
return lines;
}
/**
* Serialize the full task ledger back to markdown.
* Preserves preamble, section separators, and postamble from the original parse.
*/
export function serializeTaskLedger(ledger: TaskLedger): string {
const lines: string[] = [];
// Use original preamble if available, otherwise generate header
if (ledger.preamble.length > 0) {
lines.push(...ledger.preamble);
} else {
lines.push("# Active Tasks");
lines.push("");
}
// Active tasks
for (const task of ledger.activeTasks) {
lines.push(...serializeTask(task));
lines.push("");
}
// Use original section separator if available, otherwise generate
if (ledger.sectionSeparator.length > 0) {
lines.push(...ledger.sectionSeparator);
} else {
lines.push("# Completed");
lines.push("<!-- Move done tasks here with completion date -->");
}
lines.push("");
// Completed tasks
for (const task of ledger.completedTasks) {
lines.push(...serializeTask(task));
lines.push("");
}
// Preserve postamble
if (ledger.postamble.length > 0) {
lines.push(...ledger.postamble);
}
return lines.join("\n").trimEnd() + "\n";
}
// ============================================================================
// Sleep Cycle Integration
// ============================================================================
/**
* Review TASKS.md for stale tasks and archive them.
* This is called during the sleep cycle.
*
* @param workspaceDir - Path to the workspace directory
* @param maxAgeMs - Maximum age before a task is considered stale (default: 24h)
* @param now - Current time (for testing)
* @returns Result of the stale task review, or null if TASKS.md doesn't exist
*/
export async function reviewAndArchiveStaleTasks(
workspaceDir: string,
maxAgeMs: number = 24 * 60 * 60 * 1000,
now: Date = new Date(),
): Promise<StaleTaskResult | null> {
const tasksPath = path.join(workspaceDir, "TASKS.md");
let content: string;
try {
content = await fs.readFile(tasksPath, "utf-8");
} catch {
// TASKS.md doesn't exist — nothing to do
return null;
}
if (!content.trim()) {
return null;
}
const ledger = parseTaskLedger(content);
const staleTasks = findStaleTasks(ledger.activeTasks, now, maxAgeMs);
if (staleTasks.length === 0) {
return { staleCount: 0, archivedCount: 0, archivedIds: [] };
}
const archivedIds: string[] = [];
const nowStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
for (const task of staleTasks) {
task.status = "stale";
task.updated = nowStr;
task.isCompleted = true;
// Move from active to completed
const idx = ledger.activeTasks.indexOf(task);
if (idx !== -1) {
ledger.activeTasks.splice(idx, 1);
}
ledger.completedTasks.push(task);
archivedIds.push(task.id);
}
// Write back
const updated = serializeTaskLedger(ledger);
await fs.writeFile(tasksPath, updated, "utf-8");
return {
staleCount: staleTasks.length,
archivedCount: archivedIds.length,
archivedIds,
};
}

View File

@@ -0,0 +1,606 @@
/**
* Tests for Layer 3: Task Metadata on memories.
*
* Tests that memories can be linked to specific tasks via taskId,
* enabling precise task-aware filtering at recall and cleanup time.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { StoreMemoryInput } from "./schema.js";
import { Neo4jMemoryClient } from "./neo4j-client.js";
import { fuseWithConfidenceRRF } from "./search.js";
import { parseTaskLedger } from "./task-ledger.js";
// ============================================================================
// Test Helpers
// ============================================================================
function createMockSession() {
return {
run: vi.fn().mockResolvedValue({ records: [] }),
close: vi.fn().mockResolvedValue(undefined),
executeWrite: vi.fn(
async (work: (tx: { run: ReturnType<typeof vi.fn> }) => Promise<unknown>) => {
const mockTx = { run: vi.fn().mockResolvedValue({ records: [] }) };
return work(mockTx);
},
),
};
}
function createMockDriver() {
return {
session: vi.fn().mockReturnValue(createMockSession()),
close: vi.fn().mockResolvedValue(undefined),
};
}
function createMockLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
function createMockRecord(data: Record<string, unknown>) {
return {
get: (key: string) => data[key],
keys: Object.keys(data),
};
}
// ============================================================================
// Neo4jMemoryClient: storeMemory with taskId
// ============================================================================
describe("Task Metadata: storeMemory", () => {
let client: Neo4jMemoryClient;
let mockDriver: ReturnType<typeof createMockDriver>;
let mockSession: ReturnType<typeof createMockSession>;
beforeEach(() => {
const mockLogger = createMockLogger();
mockDriver = createMockDriver();
mockSession = createMockSession();
mockDriver.session.mockReturnValue(mockSession);
client = new Neo4jMemoryClient("bolt://localhost:7687", "neo4j", "password", 1024, mockLogger);
(client as any).driver = mockDriver;
(client as any).indexesReady = true;
});
it("should store memory with taskId when provided", async () => {
mockSession.run.mockResolvedValue({
records: [createMockRecord({ id: "mem-1" })],
});
const input: StoreMemoryInput = {
id: "mem-1",
text: "test memory with task",
embedding: [0.1, 0.2],
importance: 0.7,
category: "fact",
source: "user",
extractionStatus: "pending",
agentId: "agent-1",
taskId: "TASK-001",
};
await client.storeMemory(input);
const runCall = mockSession.run.mock.calls[0];
const cypher = runCall[0] as string;
const params = runCall[1] as Record<string, unknown>;
// Cypher should include taskId clause
expect(cypher).toContain("taskId");
// Params should include the taskId value
expect(params.taskId).toBe("TASK-001");
});
it("should store memory without taskId when not provided", async () => {
mockSession.run.mockResolvedValue({
records: [createMockRecord({ id: "mem-2" })],
});
const input: StoreMemoryInput = {
id: "mem-2",
text: "test memory without task",
embedding: [0.1, 0.2],
importance: 0.7,
category: "fact",
source: "user",
extractionStatus: "pending",
agentId: "agent-1",
};
await client.storeMemory(input);
const runCall = mockSession.run.mock.calls[0];
const cypher = runCall[0] as string;
// Cypher should NOT include taskId clause when not provided
// The dynamic clause is only added when taskId is present
expect(cypher).not.toContain(", taskId: $taskId");
});
it("backward compatibility: existing memories without taskId still work", async () => {
// Storing without taskId should work exactly as before
mockSession.run.mockResolvedValue({
records: [createMockRecord({ id: "mem-3" })],
});
const input: StoreMemoryInput = {
id: "mem-3",
text: "legacy memory",
embedding: [0.1],
importance: 0.5,
category: "other",
source: "auto-capture",
extractionStatus: "skipped",
agentId: "default",
};
const id = await client.storeMemory(input);
expect(id).toBe("mem-3");
});
});
// ============================================================================
// Neo4jMemoryClient: findMemoriesByTaskId
// ============================================================================
describe("Task Metadata: findMemoriesByTaskId", () => {
let client: Neo4jMemoryClient;
let mockDriver: ReturnType<typeof createMockDriver>;
let mockSession: ReturnType<typeof createMockSession>;
beforeEach(() => {
const mockLogger = createMockLogger();
mockDriver = createMockDriver();
mockSession = createMockSession();
mockDriver.session.mockReturnValue(mockSession);
client = new Neo4jMemoryClient("bolt://localhost:7687", "neo4j", "password", 1024, mockLogger);
(client as any).driver = mockDriver;
(client as any).indexesReady = true;
});
it("should find memories by taskId", async () => {
mockSession.run.mockResolvedValue({
records: [
createMockRecord({
id: "mem-1",
text: "task-related memory",
category: "fact",
importance: 0.8,
}),
createMockRecord({
id: "mem-2",
text: "another task memory",
category: "other",
importance: 0.6,
}),
],
});
const results = await client.findMemoriesByTaskId("TASK-001");
expect(results).toHaveLength(2);
expect(results[0].id).toBe("mem-1");
expect(results[1].id).toBe("mem-2");
const runCall = mockSession.run.mock.calls[0];
const cypher = runCall[0] as string;
const params = runCall[1] as Record<string, unknown>;
expect(cypher).toContain("m.taskId = $taskId");
expect(params.taskId).toBe("TASK-001");
});
it("should filter by agentId when provided", async () => {
mockSession.run.mockResolvedValue({ records: [] });
await client.findMemoriesByTaskId("TASK-001", "agent-1");
const runCall = mockSession.run.mock.calls[0];
const cypher = runCall[0] as string;
const params = runCall[1] as Record<string, unknown>;
expect(cypher).toContain("m.agentId = $agentId");
expect(params.agentId).toBe("agent-1");
});
it("should return empty array when no memories match", async () => {
mockSession.run.mockResolvedValue({ records: [] });
const results = await client.findMemoriesByTaskId("TASK-999");
expect(results).toHaveLength(0);
});
});
// ============================================================================
// Neo4jMemoryClient: clearTaskIdFromMemories
// ============================================================================
describe("Task Metadata: clearTaskIdFromMemories", () => {
let client: Neo4jMemoryClient;
let mockDriver: ReturnType<typeof createMockDriver>;
let mockSession: ReturnType<typeof createMockSession>;
beforeEach(() => {
const mockLogger = createMockLogger();
mockDriver = createMockDriver();
mockSession = createMockSession();
mockDriver.session.mockReturnValue(mockSession);
client = new Neo4jMemoryClient("bolt://localhost:7687", "neo4j", "password", 1024, mockLogger);
(client as any).driver = mockDriver;
(client as any).indexesReady = true;
});
it("should clear taskId from all matching memories", async () => {
mockSession.run.mockResolvedValue({
records: [createMockRecord({ cleared: 3 })],
});
const count = await client.clearTaskIdFromMemories("TASK-001");
expect(count).toBe(3);
const runCall = mockSession.run.mock.calls[0];
const cypher = runCall[0] as string;
const params = runCall[1] as Record<string, unknown>;
expect(cypher).toContain("m.taskId = $taskId");
expect(cypher).toContain("SET m.taskId = null");
expect(params.taskId).toBe("TASK-001");
});
it("should filter by agentId when provided", async () => {
mockSession.run.mockResolvedValue({
records: [createMockRecord({ cleared: 1 })],
});
await client.clearTaskIdFromMemories("TASK-001", "agent-1");
const runCall = mockSession.run.mock.calls[0];
const cypher = runCall[0] as string;
const params = runCall[1] as Record<string, unknown>;
expect(cypher).toContain("m.agentId = $agentId");
expect(params.agentId).toBe("agent-1");
});
it("should return 0 when no memories match", async () => {
mockSession.run.mockResolvedValue({
records: [createMockRecord({ cleared: 0 })],
});
const count = await client.clearTaskIdFromMemories("TASK-999");
expect(count).toBe(0);
});
});
// ============================================================================
// Hybrid search results include taskId
// ============================================================================
describe("Task Metadata: hybrid search includes taskId", () => {
it("should carry taskId through RRF fusion", () => {
const vectorResults = [
{
id: "mem-1",
text: "memory with task",
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
score: 0.9,
taskId: "TASK-001",
},
{
id: "mem-2",
text: "memory without task",
category: "other",
importance: 0.5,
createdAt: "2026-01-02",
score: 0.8,
},
];
const bm25Results = [
{
id: "mem-1",
text: "memory with task",
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
score: 0.7,
taskId: "TASK-001",
},
];
const graphResults: typeof vectorResults = [];
const fused = fuseWithConfidenceRRF(
[vectorResults, bm25Results, graphResults],
60,
[1.0, 1.0, 1.0],
);
// mem-1 should have taskId preserved
const mem1 = fused.find((r) => r.id === "mem-1");
expect(mem1).toBeDefined();
expect(mem1!.taskId).toBe("TASK-001");
// mem-2 should have undefined taskId
const mem2 = fused.find((r) => r.id === "mem-2");
expect(mem2).toBeDefined();
expect(mem2!.taskId).toBeUndefined();
});
it("should include taskId in fused results when present in any signal", () => {
// taskId present only in BM25 signal
const vectorResults = [
{
id: "mem-1",
text: "test",
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
score: 0.9,
// no taskId
},
];
const bm25Results = [
{
id: "mem-1",
text: "test",
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
score: 0.7,
taskId: "TASK-002",
},
];
const fused = fuseWithConfidenceRRF([vectorResults, bm25Results, []], 60, [1.0, 1.0, 1.0]);
// The first signal (vector) is used for metadata — taskId would be undefined
// because candidateMetadata takes the first occurrence
const mem1 = fused.find((r) => r.id === "mem-1");
expect(mem1).toBeDefined();
// The first signal to contribute metadata wins
// vector came first and has no taskId
expect(mem1!.taskId).toBeUndefined();
});
});
// ============================================================================
// Auto-tagging: parseTaskLedger for active task detection
// ============================================================================
describe("Task Metadata: auto-tagging via parseTaskLedger", () => {
it("should detect single active task for auto-tagging", () => {
const content = `# Active Tasks
## TASK-005: Fix login bug
- **Status:** in_progress
- **Started:** 2026-02-16
# Completed
## TASK-004: Fix browser port collision
- **Completed:** 2026-02-16
`;
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(1);
expect(ledger.activeTasks[0].id).toBe("TASK-005");
});
it("should not auto-tag when multiple active tasks exist", () => {
const content = `# Active Tasks
## TASK-005: Fix login bug
- **Status:** in_progress
## TASK-006: Update docs
- **Status:** in_progress
# Completed
`;
const ledger = parseTaskLedger(content);
// Multiple active tasks — should NOT auto-tag
expect(ledger.activeTasks.length).toBeGreaterThan(1);
});
it("should not auto-tag when no active tasks exist", () => {
const content = `# Active Tasks
_No active tasks_
# Completed
## TASK-004: Fix browser port collision
- **Completed:** 2026-02-16
`;
const ledger = parseTaskLedger(content);
expect(ledger.activeTasks).toHaveLength(0);
});
it("should extract completed task IDs for recall filtering", () => {
const content = `# Active Tasks
## TASK-007: New feature
- **Status:** in_progress
# Completed
## TASK-002: Book flights
- **Completed:** 2026-02-16
## TASK-003: Fix dashboard
- **Completed:** 2026-02-16
`;
const ledger = parseTaskLedger(content);
const completedTaskIds = new Set(ledger.completedTasks.map((t) => t.id));
expect(completedTaskIds.has("TASK-002")).toBe(true);
expect(completedTaskIds.has("TASK-003")).toBe(true);
expect(completedTaskIds.has("TASK-007")).toBe(false);
});
});
// ============================================================================
// Recall filter: taskId-based completed task filtering
// ============================================================================
describe("Task Metadata: recall filter", () => {
it("should filter out memories linked to completed tasks", () => {
const completedTaskIds = new Set(["TASK-002", "TASK-003"]);
const results = [
{
id: "1",
text: "active task memory",
taskId: "TASK-007",
score: 0.9,
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
},
{
id: "2",
text: "completed task memory",
taskId: "TASK-002",
score: 0.85,
category: "fact",
importance: 0.7,
createdAt: "2026-01-01",
},
{
id: "3",
text: "no task memory",
score: 0.8,
category: "other",
importance: 0.5,
createdAt: "2026-01-01",
},
{
id: "4",
text: "another completed",
taskId: "TASK-003",
score: 0.75,
category: "fact",
importance: 0.6,
createdAt: "2026-01-01",
},
];
const filtered = results.filter((r) => !r.taskId || !completedTaskIds.has(r.taskId));
expect(filtered).toHaveLength(2);
expect(filtered[0].id).toBe("1"); // active task — kept
expect(filtered[1].id).toBe("3"); // no task — kept
});
it("should keep all memories when no completed task IDs", () => {
const completedTaskIds = new Set<string>();
const results = [
{ id: "1", text: "memory A", taskId: "TASK-001", score: 0.9 },
{ id: "2", text: "memory B", score: 0.8 },
];
const filtered = results.filter((r) => !r.taskId || !completedTaskIds.has(r.taskId));
expect(filtered).toHaveLength(2);
});
it("should keep memories without taskId regardless of filter", () => {
const completedTaskIds = new Set(["TASK-001", "TASK-002"]);
const results = [
{ id: "1", text: "old memory without task", score: 0.9 },
{ id: "2", text: "another old one", taskId: undefined, score: 0.8 },
];
const filtered = results.filter((r) => !r.taskId || !completedTaskIds.has(r.taskId));
expect(filtered).toHaveLength(2);
});
});
// ============================================================================
// Vector/BM25 search results include taskId
// ============================================================================
describe("Task Metadata: search signal taskId", () => {
let client: Neo4jMemoryClient;
let mockDriver: ReturnType<typeof createMockDriver>;
let mockSession: ReturnType<typeof createMockSession>;
beforeEach(() => {
const mockLogger = createMockLogger();
mockDriver = createMockDriver();
mockSession = createMockSession();
mockDriver.session.mockReturnValue(mockSession);
client = new Neo4jMemoryClient("bolt://localhost:7687", "neo4j", "password", 1024, mockLogger);
(client as any).driver = mockDriver;
(client as any).indexesReady = true;
});
it("vector search should include taskId in results", async () => {
mockSession.run.mockResolvedValue({
records: [
createMockRecord({
id: "mem-1",
text: "test",
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
taskId: "TASK-001",
similarity: 0.95,
}),
createMockRecord({
id: "mem-2",
text: "test2",
category: "other",
importance: 0.5,
createdAt: "2026-01-02",
taskId: null, // Legacy memory without taskId
similarity: 0.85,
}),
],
});
const results = await client.vectorSearch([0.1, 0.2], 10, 0.1);
expect(results[0].taskId).toBe("TASK-001");
expect(results[1].taskId).toBeUndefined(); // null → undefined
});
it("BM25 search should include taskId in results", async () => {
mockSession.run.mockResolvedValue({
records: [
createMockRecord({
id: "mem-1",
text: "test query",
category: "fact",
importance: 0.8,
createdAt: "2026-01-01",
taskId: "TASK-002",
bm25Score: 5.0,
}),
],
});
const results = await client.bm25Search("test query", 10);
expect(results[0].taskId).toBe("TASK-002");
});
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist", "*.test.ts"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.15",
"version": "2026.2.16",
"private": true,
"description": "OpenClaw Twitch channel plugin",
"type": "module",

View File

@@ -1,5 +1,11 @@
# Changelog
## 2026.2.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.15
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.15",
"version": "2026.2.16",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@@ -3,7 +3,7 @@ import { escapeXml } from "../voice-mapping.js";
export function generateNotifyTwiml(message: string, voice: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="${voice}">${escapeXml(message)}</Say>
<Say voice="${escapeXml(voice)}">${escapeXml(message)}</Say>
<Hangup/>
</Response>`;
}

View File

@@ -244,6 +244,23 @@ export class PlivoProvider implements VoiceCallProvider {
callStatus === "no-answer" ||
callStatus === "failed"
) {
// Clean up internal maps on terminal state
if (callUuid) {
this.callUuidToWebhookUrl.delete(callUuid);
// Also clean up the reverse mapping
for (const [reqId, cUuid] of this.requestUuidToCallUuid) {
if (cUuid === callUuid) {
this.requestUuidToCallUuid.delete(reqId);
break;
}
}
}
if (callIdOverride) {
this.callIdToWebhookUrl.delete(callIdOverride);
this.pendingSpeakByCallId.delete(callIdOverride);
this.pendingListenByCallId.delete(callIdOverride);
}
return {
...baseEvent,
type: "call.ended",

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