@nowledge/openclaw-nowledge-mem 0.2.7 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-02-23
6
+
7
+ ### Changed — Architecture overhaul: tool-first, LLM-based capture
8
+
9
+ **Breaking: `autoRecall` now defaults to `false`**
10
+
11
+ The agent has full access to all 7 tools regardless of this setting. Tool-only mode (both `autoRecall` and `autoCapture` off) is now the recommended default. The agent calls `memory_search`, `nowledge_mem_save`, etc. on demand — no tokens wasted on irrelevant context injection.
12
+
13
+ Users who explicitly set `autoRecall: true` are unaffected.
14
+
15
+ **Removed: English-only heuristic capture**
16
+
17
+ The entire rule-based capture pipeline has been removed:
18
+ - `shouldCaptureAsMemory()` — English-only regex patterns (`/\bi (like|prefer|hate)\b/i`)
19
+ - `MEMORY_TRIGGER_PATTERNS`, `PROMPT_INJECTION_PATTERNS`
20
+ - `looksLikeQuestion()`, `hasMemoryTrigger()`, `looksLikePromptInjection()`
21
+ - `fingerprint()`, per-session dedup map
22
+
23
+ These were fundamentally broken for non-English users (~95% of the world) and violated the "never settle for heuristic shortcuts" principle.
24
+
25
+ **Added: Two-step LLM capture pipeline**
26
+
27
+ Replaced heuristics with a proper LLM-based pipeline:
28
+
29
+ 1. **Thread capture** (unconditional, unchanged) — full conversation appended to persistent thread
30
+ 2. **Triage** (cheap, fast) — lightweight LLM call (~50 output tokens) determines if conversation contains save-worthy content. Language-agnostic. New backend endpoint: `POST /memories/distill/triage`
31
+ 3. **Distillation** (only when worthwhile) — full LLM extraction via existing `POST /memories/distill`, creating structured memories with proper unit_type, labels, and temporal data
32
+
33
+ Cost: negligible for conversations without save-worthy content (triage only). Moderate for rich conversations (triage + distill). Works in any language.
34
+
35
+ **Enhanced: `memory_search` tool description**
36
+
37
+ More directive description for tool-only mode: tells the agent when to proactively search (past work references, previous decisions, prior context that would help).
38
+
39
+ **Migrated: `before_prompt_build` hook**
40
+
41
+ Auto-recall hook migrated from legacy `before_agent_start` to modern `before_prompt_build` API. Trimmed verbose tool guidance — the agent already sees tool descriptions in its tool list.
42
+
43
+ ### Added
44
+
45
+ - `client.triageConversation(content)` — calls `POST /memories/distill/triage`
46
+ - `client.distillThread({ threadId, title, content })` — calls `POST /memories/distill`
47
+ - Backend `POST /memories/distill/triage` endpoint with lightweight LLM triage prompt
48
+
5
49
  ## [0.2.7] - 2026-02-18
6
50
 
7
51
  ### Added — Gap closures: date range, EVOLVES CLI, WM section edit
package/CLAUDE.md ADDED
@@ -0,0 +1,138 @@
1
+ # CLAUDE.md — Nowledge Mem OpenClaw Plugin
2
+
3
+ Continuation guide for `community/nowledge-mem-openclaw-plugin`.
4
+
5
+ ## Scope
6
+
7
+ - Plugin target: OpenClaw plugin runtime (memory slot provider)
8
+ - Runtime: JS ESM modules under `src/`, no TS build pipeline
9
+ - Memory backend: `nmem` CLI (fallback: `uvx --from nmem-cli nmem`)
10
+ - Architecture: **CLI-first** — all operations go through the nmem CLI, not direct API calls
11
+ - Remote mode: set `NMEM_API_URL` + `NMEM_API_KEY` env vars or plugin config `apiUrl` + `apiKey`
12
+
13
+ ## Design Philosophy
14
+
15
+ Reflects Nowledge Mem's genuine v0.6 strengths:
16
+
17
+ 1. **Knowledge graph** — EVOLVES chains, entity relationships, typed memory connections
18
+ 2. **Source provenance** — Library ingests docs/URLs; `SOURCED_FROM` edges trace knowledge origin
19
+ 3. **Structured types** — 8 unit types: `fact | preference | decision | plan | procedure | learning | context | event`
20
+ 4. **Working Memory** — daily evolving briefing; agents can patch sections without overwriting the whole document
21
+ 5. **Cross-AI continuity** — "Your AI tools forget. We remember. Everywhere."
22
+ 6. **Hybrid search** — BM25 + semantic + graph + decay, not vector-only
23
+ 7. **Transparent scoring** — `relevance_reason` in every search result
24
+
25
+ ## Files That Matter
26
+
27
+ ```
28
+ src/
29
+ index.js — plugin registration (tools, hooks, commands, CLI)
30
+ client.js — CLI wrapper with API fallback; credential handling
31
+ config.js — strict config parsing (apiUrl, apiKey, autoRecall, etc.)
32
+ hooks/
33
+ recall.js — before_agent_start: inject Working Memory + recalled memories
34
+ capture.js — quality-gated memory note + thread append
35
+ tools/
36
+ memory-search.js — OpenClaw compat; multi-signal; bi-temporal; relevance_reason
37
+ memory-get.js — OpenClaw compat; supports MEMORY.md alias
38
+ save.js — structured capture: unit_type, labels, temporal, importance
39
+ context.js — Working Memory daily briefing (read-only)
40
+ connections.js — graph exploration: edge types, relationship strength, provenance
41
+ timeline.js — activity feed: daily grouping, event_type filter, memoryId hints
42
+ forget.js — memory deletion by ID or search
43
+ openclaw.plugin.json — manifest + config schema (version, uiHints, configSchema)
44
+ ```
45
+
46
+ ## Tool Surface (7 tools)
47
+
48
+ ### OpenClaw Memory Slot (required for system prompt activation)
49
+ - `memory_search` — multi-signal: BM25 + embedding + label + graph + decay. Returns `matchedVia` ("Text Match 100% + Semantic 69%"), `importance`, bi-temporal filters (`event_date_from/to`, `recorded_date_from/to`). Mode: `"multi-signal"`.
50
+ - `memory_get` — retrieve by `nowledgemem://memory/<id>` path or bare ID. `MEMORY.md` → Working Memory.
51
+
52
+ ### Nowledge Mem Native (differentiators)
53
+ - `nowledge_mem_save` — structured capture: `unit_type`, `labels[]`, `event_start`, `event_end`, `temporal_context`, `importance`. All fields wired to CLI and API.
54
+ - `nowledge_mem_context` — Working Memory daily briefing. Read-only by default; supports section-level patch via `patch_section` + `patch_content`/`patch_append` params (uses `nmem wm patch` client-side read-modify-write).
55
+ - `nowledge_mem_connections` — graph exploration. Edges JOIN-ed to nodes by type: CRYSTALLIZED_FROM (crystal → source memories), EVOLVES (with sub-relations: supersedes/enriches/confirms/challenges), SOURCED_FROM (document provenance), MENTIONS (entities). Each connection shows strength % and memoryId.
56
+ - `nowledge_mem_timeline` — activity feed via `nmem f`. Groups by day. `event_type` filter. Exact date range via `date_from`/`date_to` (YYYY-MM-DD). Entries include `(id: <memoryId>)` for chaining to connections.
57
+ - `nowledge_mem_forget` — delete by ID or fuzzy query.
58
+
59
+ ## Hook Surface
60
+
61
+ - `before_agent_start` — auto-recall: Working Memory + `searchRich()` with `relevanceReason` in context
62
+ - `agent_end` — quality-gated memory note + thread append (requires `autoCapture: true`)
63
+ - `after_compaction` — thread append
64
+ - `before_reset` — thread append
65
+
66
+ ## Slash Commands
67
+
68
+ - `/remember` — quick save
69
+ - `/recall` — quick search
70
+ - `/forget` — quick delete
71
+
72
+ ## Config Keys
73
+
74
+ | Key | Type | Default | Description |
75
+ |-----|------|---------|-------------|
76
+ | `autoRecall` | boolean | `false` | Inject context at session start |
77
+ | `autoCapture` | boolean | `false` | Capture notes/threads at session end |
78
+ | `maxRecallResults` | integer 1–20 | `5` | How many memories to recall |
79
+ | `apiUrl` | string | `""` | Remote server URL. Empty = local (127.0.0.1:14242) |
80
+ | `apiKey` | string | `""` | API key. Injected as `NMEM_API_KEY` env var only. Never logged. |
81
+
82
+ ### Credential Handling Rules
83
+ - `apiKey` → ONLY via child process env (`NMEM_API_KEY`). Never CLI arg, never logged.
84
+ - `apiUrl` → passed as `--api-url` flag to CLI (not a secret).
85
+ - Config values win over environment variables.
86
+ - `_spawnEnv()` builds per-spawn env; `_apiUrlArgs()` adds `--api-url` when non-default.
87
+
88
+ ## CLI Surface (nmem commands used by plugin)
89
+
90
+ | Command | Plugin method | Notes |
91
+ |---------|--------------|-------|
92
+ | `nmem --json m search <q>` | `client.search()` | Rich: relevance_reason, importance, labels, temporal |
93
+ | `nmem --json m search <q> --event-from/--event-to/--recorded-from/--recorded-to` | `client.searchTemporal()` | Bi-temporal |
94
+ | `nmem --json m add <content> [--unit-type] [-l] [--event-start] [--when]` | `client.execJson()` in save.js | Full rich save |
95
+ | `nmem --json g expand <id>` | `client.graphExpand()` | Graph neighbors + edges |
96
+ | `nmem --json g evolves <id>` | `client.graphEvolves()` | EVOLVES version chain |
97
+ | `nmem --json f [--days] [--type] [--all] [--from DATE] [--to DATE]` | `client.feedEvents()` | Activity feed + date range |
98
+ | `nmem --json wm read` | `client.readWorkingMemory()` | Working Memory read |
99
+ | `nmem --json wm patch --heading "## S" --content/--append` | `client.patchWorkingMemory()` | Section-level WM update |
100
+ | `nmem --json m delete <id>` | `client.execJson()` in forget.js | Delete |
101
+
102
+ All commands have API fallback for older CLI versions.
103
+
104
+ ## Smoke Test
105
+
106
+ ```bash
107
+ # Lint
108
+ npx biome check src/
109
+
110
+ # CLI
111
+ nmem --version
112
+ nmem --json m search "test" -n 3
113
+ nmem --json m add "test memory" --unit-type learning -l test
114
+ nmem g expand <id-from-above>
115
+ nmem g evolves <id-from-above>
116
+ nmem f --days 1
117
+ nmem f --type crystal_created
118
+ nmem f --from 2026-02-17 --to 2026-02-17
119
+ nmem wm patch --heading "## Notes" --append "New note from agent"
120
+
121
+ # Remote mode
122
+ NMEM_API_URL=https://your-server NMEM_API_KEY=key nmem status
123
+ ```
124
+
125
+ ## Known Gaps / Accepted Limitations
126
+
127
+ 1. **Feed API `date_from`/`date_to`** — supported. Backend filters events by YYYY-MM-DD range. CLI: `nmem f --from/--to`. Plugin: `nowledge_mem_timeline` accepts `date_from`/`date_to`.
128
+ 2. **Working Memory section edit** — supported. `nmem wm patch --heading "## X" --content/--append` does client-side read-modify-write. Plugin: `nowledge_mem_context` accepts `patch_section` + `patch_content`/`patch_append`.
129
+ 3. **EVOLVES chain CLI** — supported. `nmem g evolves <id>` calls `/agent/evolves?memory_id=<id>`. Plugin: `nowledge_mem_connections` uses `client.graphEvolves()`.
130
+ 4. **`unit_type` requires rebuilt backend** — `MemoryCreateRequest` includes `unit_type` (fixed). Restart backend after rebuild.
131
+ 5. **Working Memory full-overwrite only via API** — the API (`PUT /agent/working-memory`) still takes full content. The section-level patch is implemented purely client-side. This is acceptable; the Knowledge Agent regenerates WM each morning anyway.
132
+
133
+ ## Non-Goals
134
+
135
+ - Do NOT add `nowledge_mem_search` — `memory_search` covers it.
136
+ - Do NOT expose full WM overwrite from agents — section-level patch is the right granularity.
137
+ - Do NOT add cloud dependencies to the core path.
138
+ - Do NOT accept unknown config keys (strict parser in `config.js`).
package/README.md CHANGED
@@ -25,21 +25,18 @@ Start Nowledge Mem desktop app or run `nmem serve`, then configure:
25
25
  "slots": { "memory": "openclaw-nowledge-mem" },
26
26
  "entries": {
27
27
  "openclaw-nowledge-mem": {
28
- "enabled": true,
29
- "config": {
30
- "autoRecall": true,
31
- "autoCapture": false,
32
- "maxRecallResults": 5
33
- }
28
+ "enabled": true
34
29
  }
35
30
  }
36
31
  }
37
32
  }
38
33
  ```
39
34
 
35
+ That's it. The agent gets 7 tools and calls them on demand. No extra tokens wasted.
36
+
40
37
  ### Remote mode
41
38
 
42
- Connect to a Nowledge Mem server running elsewhere — on a VPS, a home server, or shared team instance. See [remote access guide](https://docs.nowledge.co/docs/remote-access) for server setup.
39
+ Connect to a Nowledge Mem server running elsewhere — on a VPS, a home server, or shared team instance. See [remote access guide](https://mem.nowledge.co/docs/remote-access) for server setup.
43
40
 
44
41
  ```json
45
42
  {
@@ -49,8 +46,6 @@ Connect to a Nowledge Mem server running elsewhere — on a VPS, a home server,
49
46
  "openclaw-nowledge-mem": {
50
47
  "enabled": true,
51
48
  "config": {
52
- "autoRecall": true,
53
- "autoCapture": false,
54
49
  "apiUrl": "https://nowledge.example.com",
55
50
  "apiKey": "your-api-key-here"
56
51
  }
@@ -123,35 +118,27 @@ last_n_days: 7
123
118
 
124
119
  **nowledge_mem_forget** — Delete a memory by ID or search query. Supports user confirmation when multiple matches are found.
125
120
 
126
- ## Hooks
121
+ ## Operating Modes
127
122
 
128
- These run automatically at OpenClaw lifecycle events — no agent decision-making involved. The LLM never calls a hidden "save" tool; the plugin's code fires at the right moments.
123
+ The plugin supports three modes. The default (tool-only) gives the agent full access to all tools with zero token overhead.
129
124
 
130
- ### Auto-Recall (`autoRecall`, default: true)
125
+ | Mode | Config | Behavior |
126
+ |------|--------|----------|
127
+ | **Tool-only** (default) | `autoRecall: false, autoCapture: false` | Agent calls tools on demand. Zero overhead. |
128
+ | **Auto-recall** | `autoRecall: true` | Working Memory + relevant memories injected at session start. |
129
+ | **Auto-capture** | `autoCapture: true` | Thread capture + LLM distillation at session end. |
131
130
 
132
- Before each agent turn, the plugin:
131
+ ### Auto-Recall (`autoRecall`, default: false)
133
132
 
134
- 1. Reads **Working Memory** (today's AI-generated briefing focus areas, flags, recent activity)
135
- 2. Searches your knowledge graph for **relevant memories** matching the current prompt
136
- 3. Prepends both as context to the system prompt, along with **tool guidance** for Nowledge Mem tools
137
-
138
- The agent receives richer context automatically. No tool call is made; no agent action is required.
133
+ When enabled, the plugin injects Working Memory and relevant search results at session start. Useful for giving the agent immediate context without waiting for it to search proactively.
139
134
 
140
135
  ### Auto-Capture (`autoCapture`, default: false)
141
136
 
142
- At three lifecycle points `agent_end`, `after_compaction`, `before_reset` — the plugin does two independent things:
143
-
144
- **1. Thread transcript (always)**
145
- The full conversation is appended to a persistent thread in Nowledge Mem, keyed by a stable session ID. This happens unconditionally on every successful session end, regardless of what was said. The thread is searchable via `nowledge_mem_timeline` and `nmem t` commands.
146
-
147
- **2. Memory note (conditional)**
148
- If the last user message matches a memory-trigger pattern (decision, preference, fact, entity — e.g. "I prefer TypeScript", "we chose Postgres"), a separate structured memory is also created. Questions, slash commands, and injected-context blocks are skipped. The memory note is independent of the thread — both can happen, either, or neither.
149
-
150
- **Compaction captures**: when OpenClaw compresses a long conversation to fit the model's context window, the plugin fires `after_compaction` and appends the pre-compaction transcript to the thread. Messages that get compressed away are not lost.
137
+ When enabled, two things happen at `agent_end`, `after_compaction`, and `before_reset`:
151
138
 
152
- **Deduplication**: thread appends are idempotent by `external_id`. If the same hook fires twice (e.g. both `agent_end` and `before_reset`), messages are deduplicated — no duplicates in Nowledge Mem.
139
+ **1. Thread capture (always).** The full conversation is appended to a persistent thread. Unconditional, idempotent by message ID.
153
140
 
154
- **Quality gates** (memory note only): skips messages shorter than 24 characters, fewer than 5 words, questions, slash commands, prompt-injection patterns, and LLM-generated context blocks.
141
+ **2. LLM distillation (when worthwhile).** A lightweight LLM triage determines if the conversation has save-worthy content. If yes, a full distillation pass creates structured memories with types, labels, and temporal data. Language-agnostic works in any language.
155
142
 
156
143
  ## Slash Commands
157
144
 
@@ -172,9 +159,9 @@ openclaw nowledge-mem status
172
159
 
173
160
  | Key | Type | Default | Description |
174
161
  |-----|------|---------|-------------|
175
- | `autoRecall` | boolean | `true` | Inject Working Memory + relevant memories at session start |
176
- | `autoCapture` | boolean | `false` | Capture knowledge notes and thread transcripts across lifecycle hooks |
177
- | `maxRecallResults` | integer | `5` | Max memories to recall (1-20) |
162
+ | `autoRecall` | boolean | `false` | Inject Working Memory + relevant memories at session start |
163
+ | `autoCapture` | boolean | `false` | Thread capture + LLM distillation at session end |
164
+ | `maxRecallResults` | integer | `5` | Max memories to recall at session start (only used when autoRecall is enabled) |
178
165
 
179
166
  ## What Makes This Different
180
167
 
@@ -1,19 +1,23 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.2.7",
3
+ "version": "0.6.2",
4
4
  "kind": "memory",
5
5
  "uiHints": {
6
6
  "autoRecall": {
7
7
  "label": "Auto-recall at session start",
8
- "help": "Inject Working Memory briefing and relevant memories when a new session begins"
8
+ "help": "Inject Working Memory and relevant memories at session start. Off by default \u2014 the agent can call memory_search on demand instead."
9
9
  },
10
10
  "autoCapture": {
11
11
  "label": "Auto-capture at session end",
12
- "help": "Store high-value notes on agent_end / after_compaction / before_reset"
12
+ "help": "Capture conversation threads and distill key memories via LLM at session end"
13
+ },
14
+ "captureMinInterval": {
15
+ "label": "Minimum capture interval (seconds)",
16
+ "help": "Minimum seconds between auto-captures for the same thread. Prevents heartbeat-driven burst captures. Set to 0 for no limit."
13
17
  },
14
18
  "maxRecallResults": {
15
19
  "label": "Max recall results",
16
- "help": "How many memories to inject for each recall cycle (1–20)"
20
+ "help": "How many memories to inject for each recall cycle (1\u201320)"
17
21
  },
18
22
  "apiUrl": {
19
23
  "label": "Server URL (remote mode)",
@@ -21,7 +25,7 @@
21
25
  },
22
26
  "apiKey": {
23
27
  "label": "API key (remote mode)",
24
- "help": "Required when connecting to a remote server. Never logged or passed as a CLI argument injected as NMEM_API_KEY env var only.",
28
+ "help": "Required when connecting to a remote server. Never logged or passed as a CLI argument \u2014 injected as NMEM_API_KEY env var only.",
25
29
  "secret": true
26
30
  }
27
31
  },
@@ -30,20 +34,27 @@
30
34
  "properties": {
31
35
  "autoRecall": {
32
36
  "type": "boolean",
33
- "default": true,
34
- "description": "Inject Working Memory and recalled memories at session start"
37
+ "default": false,
38
+ "description": "Inject Working Memory and recalled memories at session start. Off by default \u2014 the agent queries memory on demand via tools."
35
39
  },
36
40
  "autoCapture": {
37
41
  "type": "boolean",
38
42
  "default": false,
39
- "description": "Enable memory note capture across session lifecycle hooks"
43
+ "description": "Capture conversation threads and distill key memories via LLM at session end"
44
+ },
45
+ "captureMinInterval": {
46
+ "type": "integer",
47
+ "default": 300,
48
+ "minimum": 0,
49
+ "maximum": 86400,
50
+ "description": "Minimum seconds between auto-captures for the same thread (0 = no limit). Prevents heartbeat-driven burst captures."
40
51
  },
41
52
  "maxRecallResults": {
42
53
  "type": "integer",
43
54
  "default": 5,
44
55
  "minimum": 1,
45
56
  "maximum": 20,
46
- "description": "Maximum memories to recall at session start"
57
+ "description": "Maximum memories to recall at session start (only used when autoRecall is enabled)"
47
58
  },
48
59
  "apiUrl": {
49
60
  "type": "string",
@@ -53,7 +64,7 @@
53
64
  "apiKey": {
54
65
  "type": "string",
55
66
  "default": "",
56
- "description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var never logged."
67
+ "description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var \u2014 never logged."
57
68
  }
58
69
  },
59
70
  "additionalProperties": false
package/package.json CHANGED
@@ -1,42 +1,19 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
- "version": "0.2.7",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
- "description": "Nowledge Mem memory plugin for OpenClaw local-first knowledge graph memory across all your AI tools",
6
- "keywords": [
7
- "openclaw",
8
- "openclaw-plugin",
9
- "memory",
10
- "knowledge-graph",
11
- "nowledge",
12
- "nowledge-mem",
13
- "ai-memory",
14
- "local-first"
15
- ],
5
+ "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
16
6
  "author": {
17
7
  "name": "Nowledge Labs",
18
8
  "email": "hello@nowledge-labs.ai",
19
9
  "url": "https://nowledge-labs.ai"
20
10
  },
21
11
  "license": "MIT",
22
- "homepage": "https://mem.nowledge.co/docs/integrations/openclaw",
23
12
  "repository": {
24
13
  "type": "git",
25
14
  "url": "https://github.com/nowledge-co/community.git",
26
15
  "directory": "nowledge-mem-openclaw-plugin"
27
16
  },
28
- "bugs": {
29
- "url": "https://github.com/nowledge-co/community/issues"
30
- },
31
- "engines": {
32
- "node": ">=18.0.0"
33
- },
34
- "files": [
35
- "src/",
36
- "openclaw.plugin.json",
37
- "README.md",
38
- "CHANGELOG.md"
39
- ],
40
17
  "openclaw": {
41
18
  "extensions": ["./src/index.js"]
42
19
  },
package/src/client.js CHANGED
@@ -16,10 +16,10 @@ function patchWmSection(currentContent, heading, { content, append } = {}) {
16
16
  const levelMatch = headingLc.match(/^(#{1,6})\s/);
17
17
  const targetLevel = levelMatch ? levelMatch[1].length : 2;
18
18
 
19
- // Find the start of the section
19
+ // Find the start of the section (exact heading match, not substring)
20
20
  let startIdx = -1;
21
21
  for (let i = 0; i < lines.length; i++) {
22
- if (lines[i].trim().toLowerCase().includes(headingLc)) {
22
+ if (lines[i].trim().toLowerCase() === headingLc) {
23
23
  startIdx = i;
24
24
  break;
25
25
  }
@@ -48,11 +48,9 @@ function patchWmSection(currentContent, heading, { content, append } = {}) {
48
48
  }
49
49
 
50
50
  const newSection = [headingLine, newBody].filter(Boolean).join("\n");
51
- return [
52
- ...lines.slice(0, startIdx),
53
- newSection,
54
- ...lines.slice(endIdx),
55
- ].join("\n");
51
+ return [...lines.slice(0, startIdx), newSection, ...lines.slice(endIdx)].join(
52
+ "\n",
53
+ );
56
54
  }
57
55
 
58
56
  /**
@@ -81,7 +79,8 @@ export class NowledgeMemClient {
81
79
  this.logger = logger;
82
80
  this.nmemCmd = null;
83
81
  // Resolved once from config + env (config wins over env, both win over default)
84
- this._apiUrl = (credentials.apiUrl || "").trim() || "http://127.0.0.1:14242";
82
+ this._apiUrl =
83
+ (credentials.apiUrl || "").trim() || "http://127.0.0.1:14242";
85
84
  this._apiKey = (credentials.apiKey || "").trim();
86
85
  }
87
86
 
@@ -193,12 +192,16 @@ export class NowledgeMemClient {
193
192
  const cmd = this.resolveCommand();
194
193
  const [bin, ...baseArgs] = cmd;
195
194
  try {
196
- const result = spawnSync(bin, [...baseArgs, ...this._apiUrlArgs(), ...args], {
197
- stdio: ["ignore", "pipe", "pipe"],
198
- timeout,
199
- encoding: "utf-8",
200
- env: this._spawnEnv(),
201
- });
195
+ const result = spawnSync(
196
+ bin,
197
+ [...baseArgs, ...this._apiUrlArgs(), ...args],
198
+ {
199
+ stdio: ["ignore", "pipe", "pipe"],
200
+ timeout,
201
+ encoding: "utf-8",
202
+ env: this._spawnEnv(),
203
+ },
204
+ );
202
205
  if (result.error) {
203
206
  throw result.error;
204
207
  }
@@ -287,7 +290,8 @@ export class NowledgeMemClient {
287
290
  ];
288
291
  if (eventDateFrom) args.push("--event-from", String(eventDateFrom));
289
292
  if (eventDateTo) args.push("--event-to", String(eventDateTo));
290
- if (recordedDateFrom) args.push("--recorded-from", String(recordedDateFrom));
293
+ if (recordedDateFrom)
294
+ args.push("--recorded-from", String(recordedDateFrom));
291
295
  if (recordedDateTo) args.push("--recorded-to", String(recordedDateTo));
292
296
 
293
297
  let data;
@@ -306,9 +310,13 @@ export class NowledgeMemClient {
306
310
  if (query) qs.set("q", String(query));
307
311
  if (eventDateFrom) qs.set("event_date_from", String(eventDateFrom));
308
312
  if (eventDateTo) qs.set("event_date_to", String(eventDateTo));
309
- if (recordedDateFrom) qs.set("recorded_date_from", String(recordedDateFrom));
313
+ if (recordedDateFrom)
314
+ qs.set("recorded_date_from", String(recordedDateFrom));
310
315
  if (recordedDateTo) qs.set("recorded_date_to", String(recordedDateTo));
311
- const apiData = await this.apiJson("GET", `/memories/search?${qs.toString()}`);
316
+ const apiData = await this.apiJson(
317
+ "GET",
318
+ `/memories/search?${qs.toString()}`,
319
+ );
312
320
  // Normalize from API response format
313
321
  const apiMems = (apiData.memories ?? []).map((m) => ({
314
322
  id: String(m.id ?? ""),
@@ -322,7 +330,10 @@ export class NowledgeMemClient {
322
330
  event_end: m.metadata?.event_end ?? null,
323
331
  temporal_context: m.metadata?.temporal_context ?? null,
324
332
  }));
325
- return { memories: apiMems.map((m) => this._normalizeMemory(m)), searchMetadata: apiData.search_metadata ?? {} };
333
+ return {
334
+ memories: apiMems.map((m) => this._normalizeMemory(m)),
335
+ searchMetadata: apiData.search_metadata ?? {},
336
+ };
326
337
  }
327
338
 
328
339
  const memories = data.memories ?? data.results ?? [];
@@ -395,7 +406,14 @@ export class NowledgeMemClient {
395
406
  * with relation type: replaces | enriches | confirms | challenges.
396
407
  */
397
408
  async graphEvolves(memoryId, { limit = 20 } = {}) {
398
- const args = ["--json", "g", "evolves", String(memoryId), "-n", String(limit)];
409
+ const args = [
410
+ "--json",
411
+ "g",
412
+ "evolves",
413
+ String(memoryId),
414
+ "-n",
415
+ String(limit),
416
+ ];
399
417
  try {
400
418
  return this.execJson(args);
401
419
  } catch (err) {
@@ -465,7 +483,10 @@ export class NowledgeMemClient {
465
483
  if (eventType) qs.set("event_type", eventType);
466
484
  if (dateFrom) qs.set("date_from", String(dateFrom));
467
485
  if (dateTo) qs.set("date_to", String(dateTo));
468
- const data = await this.apiJson("GET", `/agent/feed/events?${qs.toString()}`);
486
+ const data = await this.apiJson(
487
+ "GET",
488
+ `/agent/feed/events?${qs.toString()}`,
489
+ );
469
490
  return Array.isArray(data) ? data : (data.events ?? []);
470
491
  }
471
492
  }
@@ -648,7 +669,9 @@ export class NowledgeMemClient {
648
669
  if (!notSupported) throw err;
649
670
 
650
671
  // Fallback: full-document write (read → patch inline → write)
651
- this.logger.warn("patchWorkingMemory: CLI too old, falling back to full replace");
672
+ this.logger.warn(
673
+ "patchWorkingMemory: CLI too old, falling back to full replace",
674
+ );
652
675
  const current = await this.readWorkingMemory();
653
676
  if (!current.available) throw new Error("Working Memory not available");
654
677
 
@@ -662,6 +685,43 @@ export class NowledgeMemClient {
662
685
  }
663
686
  }
664
687
 
688
+ // ── Distillation ─────────────────────────────────────────────────────────
689
+
690
+ /**
691
+ * Lightweight triage: determine if a conversation has save-worthy content.
692
+ * Uses a cheap LLM call (~50 output tokens) on the backend.
693
+ *
694
+ * @param {string} content Conversation text to triage
695
+ * @returns {{ should_distill: boolean, reason: string }}
696
+ */
697
+ async triageConversation(content) {
698
+ return this.apiJson("POST", "/memories/distill/triage", {
699
+ thread_content: String(content || ""),
700
+ });
701
+ }
702
+
703
+ /**
704
+ * Distill memories from a conversation thread.
705
+ * Calls the full distillation endpoint which uses LLM to extract
706
+ * structured memories (with unit_type, temporal data, labels).
707
+ *
708
+ * @param {{ threadId: string, title?: string, content: string }} params
709
+ */
710
+ async distillThread({ threadId, title, content }) {
711
+ return this.apiJson(
712
+ "POST",
713
+ "/memories/distill",
714
+ {
715
+ thread_id: String(threadId || ""),
716
+ thread_title: title || "",
717
+ thread_content: String(content || ""),
718
+ distillation_type: "simple_llm",
719
+ extraction_level: "swift",
720
+ },
721
+ 60_000, // 60s timeout for LLM distillation
722
+ );
723
+ }
724
+
665
725
  async checkHealth() {
666
726
  try {
667
727
  this.exec(["status"]);
package/src/config.js CHANGED
@@ -1,6 +1,18 @@
1
+ export const API_DEFAULT_URL = "http://127.0.0.1:14242";
2
+
3
+ /**
4
+ * Check whether an API URL points to the local default server.
5
+ * Useful for mode detection (local vs remote) in UI, CORS, diagnostics.
6
+ */
7
+ export function isDefaultApiUrl(url) {
8
+ const trimmed = (url || "").trim().replace(/\/+$/, "");
9
+ return !trimmed || trimmed === API_DEFAULT_URL;
10
+ }
11
+
1
12
  const ALLOWED_KEYS = new Set([
2
13
  "autoRecall",
3
14
  "autoCapture",
15
+ "captureMinInterval",
4
16
  "maxRecallResults",
5
17
  "apiUrl",
6
18
  "apiKey",
@@ -9,10 +21,15 @@ const ALLOWED_KEYS = new Set([
9
21
  export function parseConfig(raw) {
10
22
  const obj = raw && typeof raw === "object" ? raw : {};
11
23
 
12
- for (const key of Object.keys(obj)) {
13
- if (!ALLOWED_KEYS.has(key)) {
14
- throw new Error(`Unknown config key: "${key}"`);
15
- }
24
+ // Strict: reject unknown keys so users catch typos immediately.
25
+ // If OpenClaw adds new platform-level keys that should pass through,
26
+ // add them to ALLOWED_KEYS rather than silently accepting anything.
27
+ const unknownKeys = Object.keys(obj).filter((k) => !ALLOWED_KEYS.has(k));
28
+ if (unknownKeys.length > 0) {
29
+ throw new Error(
30
+ `nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}. ` +
31
+ `Allowed keys: ${[...ALLOWED_KEYS].join(", ")}`,
32
+ );
16
33
  }
17
34
 
18
35
  // apiUrl: config wins, then env var, then local default
@@ -30,8 +47,13 @@ export function parseConfig(raw) {
30
47
  "";
31
48
 
32
49
  return {
33
- autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall : true,
50
+ autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall : false,
34
51
  autoCapture: typeof obj.autoCapture === "boolean" ? obj.autoCapture : false,
52
+ captureMinInterval:
53
+ typeof obj.captureMinInterval === "number" &&
54
+ Number.isFinite(obj.captureMinInterval)
55
+ ? Math.min(86400, Math.max(0, Math.trunc(obj.captureMinInterval)))
56
+ : 300,
35
57
  maxRecallResults:
36
58
  typeof obj.maxRecallResults === "number" &&
37
59
  Number.isFinite(obj.maxRecallResults)
@@ -2,6 +2,26 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
 
4
4
  const MAX_MESSAGE_CHARS = 800;
5
+ const MAX_DISTILL_MESSAGE_CHARS = 2000;
6
+ const MAX_CONVERSATION_CHARS = 30_000;
7
+ const MIN_MESSAGES_FOR_DISTILL = 4;
8
+
9
+ // Per-thread triage cooldown: prevents burst triage/distillation from heartbeat.
10
+ // Maps threadId -> timestamp (ms) of last successful triage.
11
+ // Evicted opportunistically when new entries are set (see _setLastCapture).
12
+ const _lastCaptureAt = new Map();
13
+ const _MAX_COOLDOWN_ENTRIES = 200;
14
+
15
+ function _setLastCapture(threadId, now) {
16
+ _lastCaptureAt.set(threadId, now);
17
+ // Opportunistic eviction: sweep stale entries when map grows large
18
+ if (_lastCaptureAt.size > _MAX_COOLDOWN_ENTRIES) {
19
+ const cutoff = now - 86_400_000; // 24h — generous TTL
20
+ for (const [key, ts] of _lastCaptureAt) {
21
+ if (ts < cutoff) _lastCaptureAt.delete(key);
22
+ }
23
+ }
24
+ }
5
25
 
6
26
  function truncate(text, max = MAX_MESSAGE_CHARS) {
7
27
  const str = String(text || "").trim();
@@ -61,73 +81,12 @@ function normalizeRoleMessage(raw) {
61
81
  return {
62
82
  role,
63
83
  content: truncate(text),
84
+ fullContent: text,
64
85
  timestamp,
65
86
  externalHint,
66
87
  };
67
88
  }
68
89
 
69
- function fingerprint(text) {
70
- return String(text || "")
71
- .toLowerCase()
72
- .replace(/\s+/g, " ")
73
- .replace(/[^\w\s]/g, "")
74
- .slice(0, 180);
75
- }
76
-
77
- const PROMPT_INJECTION_PATTERNS = [
78
- /ignore (all|any|previous|above|prior) instructions/i,
79
- /do not follow (the )?(system|developer)/i,
80
- /system prompt/i,
81
- /developer message/i,
82
- /<\s*(system|assistant|developer|tool|function)\b/i,
83
- /\b(run|execute|call|invoke)\b.{0,40}\b(tool|command)\b/i,
84
- ];
85
-
86
- const MEMORY_TRIGGER_PATTERNS = [
87
- /\bi (like|prefer|hate|love|want|need|use|chose|decided)\b/i,
88
- /\bwe (decided|agreed|chose|will use|are using|should)\b/i,
89
- /\b(always|never|important|remember)\b/i,
90
- /\b(my|our) (\w+ )?is\b/i,
91
- /[\w.-]+@[\w.-]+\.\w+/,
92
- /\+\d{10,}/,
93
- ];
94
-
95
- function looksLikeQuestion(text) {
96
- const trimmed = text.trim();
97
- if (trimmed.endsWith("?")) return true;
98
- if (
99
- /^(what|how|why|when|where|which|who|can|could|would|should|do|does|did|is|are|was|were)\b/i.test(
100
- trimmed,
101
- )
102
- ) {
103
- return true;
104
- }
105
- return false;
106
- }
107
-
108
- function looksLikePromptInjection(text) {
109
- const normalized = text.replace(/\s+/g, " ").trim();
110
- if (!normalized) return false;
111
- return PROMPT_INJECTION_PATTERNS.some((p) => p.test(normalized));
112
- }
113
-
114
- function hasMemoryTrigger(text) {
115
- return MEMORY_TRIGGER_PATTERNS.some((p) => p.test(text));
116
- }
117
-
118
- function shouldCaptureAsMemory(text) {
119
- const normalized = String(text || "").trim();
120
- if (!normalized) return false;
121
- if (normalized.startsWith("/")) return false;
122
- if (normalized.length < 24) return false;
123
- if (normalized.split(/\s+/).length < 5) return false;
124
- if (looksLikeQuestion(normalized)) return false;
125
- if (looksLikePromptInjection(normalized)) return false;
126
- if (normalized.includes("<relevant-memories>")) return false;
127
- if (normalized.startsWith("<") && normalized.includes("</")) return false;
128
- return hasMemoryTrigger(normalized);
129
- }
130
-
131
90
  function buildThreadTitle(ctx, reason) {
132
91
  const session = ctx?.sessionKey || ctx?.sessionId || "session";
133
92
  const reasonSuffix = reason ? ` (${reason})` : "";
@@ -247,15 +206,16 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
247
206
  deduplicate: true,
248
207
  idempotencyKey,
249
208
  });
209
+ const added = appended.messagesAdded ?? 0;
250
210
  logger.info(
251
- `capture: appended ${appended.messagesAdded} messages to ${threadId} (${reason || "event"})`,
211
+ `capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
252
212
  );
253
- return;
213
+ return { threadId, normalized, messagesAdded: added };
254
214
  } catch (err) {
255
215
  if (!client.isThreadNotFoundError(err)) {
256
216
  const message = err instanceof Error ? err.message : String(err);
257
217
  logger.warn(`capture: thread append failed for ${threadId}: ${message}`);
258
- return;
218
+ return null;
259
219
  }
260
220
  }
261
221
 
@@ -269,32 +229,60 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
269
229
  logger.info(
270
230
  `capture: created thread ${createdId} with ${messages.length} messages (${reason || "event"})`,
271
231
  );
232
+ return { threadId, normalized, messagesAdded: messages.length };
272
233
  } catch (err) {
273
234
  const message = err instanceof Error ? err.message : String(err);
274
235
  logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Build a plain-text conversation string from normalized messages.
242
+ * Used as input for triage and distillation.
243
+ *
244
+ * Per-message content is capped at MAX_DISTILL_MESSAGE_CHARS and the
245
+ * total output at MAX_CONVERSATION_CHARS to keep LLM API payloads
246
+ * bounded — long coding sessions with large code blocks can produce
247
+ * arbitrarily large fullContent.
248
+ */
249
+ function buildConversationText(normalized) {
250
+ const parts = [];
251
+ let total = 0;
252
+ for (const m of normalized) {
253
+ const text = truncate(m.fullContent || m.content, MAX_DISTILL_MESSAGE_CHARS);
254
+ const line = `${m.role}: ${text}`;
255
+ if (total + line.length > MAX_CONVERSATION_CHARS) break;
256
+ parts.push(line);
257
+ total += line.length + 2; // account for "\n\n" separator
275
258
  }
259
+ return parts.join("\n\n");
276
260
  }
277
261
 
278
262
  /**
279
- * Capture thread + optional memory note after a successful agent run.
263
+ * Capture thread + LLM-based distillation after a successful agent run.
280
264
  *
281
- * Thread capture and memory note capture are intentionally independent:
282
- * - Thread append: always attempted when event.success is true and messages exist.
283
- * appendOrCreateThread self-guards on empty messages.
284
- * - Memory note: only when the last user message matches a trigger pattern.
285
- * This is an additional signal, not the gating condition for thread capture.
265
+ * Two independent operations (agent_end only):
266
+ * 1. Thread append: always attempted (unconditional, idempotent).
267
+ * 2. Triage + distill: only if enough messages AND cheap LLM triage
268
+ * determines the conversation has save-worthy content. This replaces
269
+ * the old English-only regex heuristic with language-agnostic LLM
270
+ * classification.
286
271
  *
287
- * Previous bug: both were gated behind shouldCaptureAsMemory, so sessions
288
- * ending with a question or a command were silently dropped from threads.
272
+ * Note: LLM distillation (step 2) runs exclusively in this agent_end handler.
273
+ * The before_reset / after_compaction handlers only capture threads no
274
+ * triage or distillation, since those are mid-session checkpoints.
289
275
  */
290
- export function buildAgentEndCaptureHandler(client, _cfg, logger) {
291
- const seenBySession = new Map();
276
+ export function buildAgentEndCaptureHandler(client, cfg, logger) {
277
+ const cooldownMs = (cfg.captureMinInterval ?? 300) * 1000;
292
278
 
293
279
  return async (event, ctx) => {
294
280
  if (!event?.success) return;
295
281
 
296
- // 1. Always thread-append this session (idempotent, self-guards on empty messages).
297
- await appendOrCreateThread({
282
+ // 1. Always thread-append (idempotent, self-guards on empty messages).
283
+ // Never skip this — messages must always be persisted regardless of
284
+ // cooldown state, since appendOrCreateThread is deduped and cheap.
285
+ const result = await appendOrCreateThread({
298
286
  client,
299
287
  logger,
300
288
  event,
@@ -302,32 +290,83 @@ export function buildAgentEndCaptureHandler(client, _cfg, logger) {
302
290
  reason: "agent_end",
303
291
  });
304
292
 
305
- // 2. Optionally save a memory note if the last user message is worth capturing.
306
- // This is a separate, weaker signal do not let it gate the thread append above.
307
- if (!Array.isArray(event?.messages)) return;
308
- const normalized = event.messages.map(normalizeRoleMessage).filter(Boolean);
309
- const lastUser = [...normalized].reverse().find((m) => m.role === "user");
310
- if (!lastUser || !shouldCaptureAsMemory(lastUser.content)) return;
293
+ // 2. Triage + distill: language-agnostic LLM-based capture.
294
+ // Defensive guardregistration in index.js already gates on autoCapture,
295
+ // but check here too so the handler is safe if called directly.
296
+ if (!cfg.autoCapture) return;
297
+
298
+ // Skip when no new messages were added (e.g. heartbeat re-sync).
299
+ if (!result || result.messagesAdded === 0) {
300
+ logger.debug?.("capture: no new messages since last sync, skipping triage");
301
+ return;
302
+ }
303
+
304
+ // Triage cooldown: skip expensive LLM triage/distillation if this
305
+ // thread was already triaged recently. Thread append above still ran,
306
+ // so no messages are lost — only the LLM cost is avoided.
307
+ if (cooldownMs > 0 && result.threadId) {
308
+ const lastCapture = _lastCaptureAt.get(result.threadId) || 0;
309
+ if (Date.now() - lastCapture < cooldownMs) {
310
+ logger.debug?.(
311
+ `capture: triage cooldown active for ${result.threadId}, skipping`,
312
+ );
313
+ return;
314
+ }
315
+ }
316
+
317
+ // Skip short conversations — not worth the triage cost.
318
+ if (
319
+ !result.normalized ||
320
+ result.normalized.length < MIN_MESSAGES_FOR_DISTILL
321
+ ) {
322
+ return;
323
+ }
311
324
 
312
- const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "session");
313
- const nextFp = fingerprint(lastUser.content);
314
- const previousFp = seenBySession.get(sessionKey);
315
- if (previousFp === nextFp) return;
316
- seenBySession.set(sessionKey, nextFp);
325
+ const conversationText = buildConversationText(result.normalized);
326
+ if (conversationText.length < 100) return;
327
+
328
+ // Record cooldown AFTER all eligibility checks pass, right before
329
+ // the expensive LLM call. If triage was skipped by filters above,
330
+ // the cooldown stays unset so the next call can retry.
331
+ if (cooldownMs > 0 && result.threadId) {
332
+ _setLastCapture(result.threadId, Date.now());
333
+ }
317
334
 
318
335
  try {
319
- const title = `OpenClaw note (${sessionKey})`;
320
- const id = await client.addMemory(lastUser.content, title, 0.65);
321
- logger.info(`capture: stored memory ${id}`);
336
+ const triage = await client.triageConversation(conversationText);
337
+ if (!triage?.should_distill) {
338
+ logger.debug?.(
339
+ `capture: triage skipped distillation — ${triage?.reason || "no reason"}`,
340
+ );
341
+ return;
342
+ }
343
+
344
+ logger.info(`capture: triage passed — ${triage.reason}`);
345
+
346
+ const distillResult = await client.distillThread({
347
+ threadId: result.threadId,
348
+ title: buildThreadTitle(ctx, "distilled"),
349
+ content: conversationText,
350
+ });
351
+
352
+ const count =
353
+ distillResult?.memories_created ??
354
+ distillResult?.created_memories?.length ??
355
+ 0;
356
+ logger.info(
357
+ `capture: distilled ${count} memories from ${result.threadId}`,
358
+ );
322
359
  } catch (err) {
323
360
  const message = err instanceof Error ? err.message : String(err);
324
- logger.warn(`capture: memory store failed: ${message}`);
361
+ logger.warn(`capture: triage/distill failed: ${message}`);
362
+ // Not fatal — thread is already captured above.
325
363
  }
326
364
  };
327
365
  }
328
366
 
329
367
  /**
330
368
  * Capture thread messages before reset or after compaction.
369
+ * Thread-only (no distillation) — these are lifecycle checkpoints.
331
370
  */
332
371
  export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
333
372
  return async (event, ctx) => {
@@ -16,17 +16,12 @@ function escapeForPrompt(text) {
16
16
  /**
17
17
  * Builds the before_agent_start hook handler.
18
18
  *
19
- * Injects two layers of context:
19
+ * Injects two layers of context at session start:
20
20
  * 1. Working Memory — today's focus, priorities, unresolved flags
21
21
  * 2. Relevant memories — with types, labels, and source provenance
22
22
  *
23
- * The context framing is designed to make the agent use Nowledge Mem's
24
- * native tools (nowledge_mem_save, nowledge_mem_connections) when
25
- * appropriate, rather than just answering from injected snippets.
26
- *
27
- * Source provenance: memories extracted from Library documents carry
28
- * SOURCED_FROM edges. The nowledge_mem_connections tool surfaces these
29
- * when exploring graph neighborhoods.
23
+ * Tool guidance is minimal the agent already sees full tool descriptions
24
+ * in its tool list. We only add a brief behavioral note.
30
25
  */
31
26
  export function buildRecallHandler(client, cfg, logger) {
32
27
  return async (event) => {
@@ -47,22 +42,21 @@ export function buildRecallHandler(client, cfg, logger) {
47
42
  logger.error(`recall: working memory read failed: ${err}`);
48
43
  }
49
44
 
50
- // 2. Relevant memories — enriched with scoring signals and labels
51
- try {
52
- const results = await client.searchRich(prompt, cfg.maxRecallResults);
53
- if (results.length > 0) {
54
- const lines = results.map((r) => {
55
- const title = r.title || "(untitled)";
56
- const score = `${(r.score * 100).toFixed(0)}%`;
57
- const labels =
58
- Array.isArray(r.labels) && r.labels.length > 0
59
- ? ` [${r.labels.join(", ")}]`
60
- : "";
61
- // Show the scoring breakdown so the agent understands match quality
62
- const matchHint = r.relevanceReason ? ` — ${r.relevanceReason}` : "";
63
- const snippet = escapeForPrompt(r.content.slice(0, 250));
64
- return `${title} (${score}${matchHint})${labels}: ${snippet}`;
65
- });
45
+ // 2. Relevant memories — enriched with scoring signals and labels
46
+ try {
47
+ const results = await client.searchRich(prompt, cfg.maxRecallResults);
48
+ if (results.length > 0) {
49
+ const lines = results.map((r) => {
50
+ const title = r.title || "(untitled)";
51
+ const score = `${(r.score * 100).toFixed(0)}%`;
52
+ const labels =
53
+ Array.isArray(r.labels) && r.labels.length > 0
54
+ ? ` [${r.labels.join(", ")}]`
55
+ : "";
56
+ const matchHint = r.relevanceReason ? ` ${r.relevanceReason}` : "";
57
+ const snippet = escapeForPrompt(r.content.slice(0, 250));
58
+ return `${title} (${score}${matchHint})${labels}: ${snippet}`;
59
+ });
66
60
  sections.push(
67
61
  [
68
62
  "<recalled-knowledge>",
@@ -81,25 +75,10 @@ export function buildRecallHandler(client, cfg, logger) {
81
75
  const context = [
82
76
  "<nowledge-mem>",
83
77
  "Context from the user's personal knowledge graph (Nowledge Mem).",
84
- "The graph contains memories, entities, and source documents (Library files and URLs).",
85
- "",
86
- "Tool guidance:",
87
- "- memory_search: find memories by topic (semantic + BM25 + graph signals — not just keyword matching)",
88
- "- memory_get: read a full memory by its nowledgemem://memory/<id> path",
89
- "- nowledge_mem_connections: cross-topic synthesis and provenance — use when asked how topics relate,",
90
- " which document knowledge came from, or how understanding evolved over time",
91
- "- nowledge_mem_timeline: temporal queries — 'what was I working on last week?', 'what happened yesterday?'",
92
- " Use last_n_days=1 for today, 7 for this week, 30 for this month",
93
- "- nowledge_mem_save: proactively save insights, decisions, preferences — don't wait to be asked",
94
- "- nowledge_mem_context: read today's Working Memory (focus areas, priorities, flags)",
95
- "- nowledge_mem_forget: delete a memory by id or query",
96
78
  "",
97
79
  ...sections,
98
80
  "",
99
- "Act on recalled knowledge naturally.",
100
- "For topic connections and source provenance: use nowledge_mem_connections.",
101
- "For 'what was I doing last week/yesterday?': use nowledge_mem_timeline.",
102
- "When conversation produces a valuable insight or decision: save it with nowledge_mem_save.",
81
+ "Act on recalled knowledge naturally. When the conversation produces a valuable insight or decision, save it with nowledge_mem_save.",
103
82
  "</nowledge-mem>",
104
83
  ].join("\n");
105
84
 
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  createRecallCommand,
6
6
  createRememberCommand,
7
7
  } from "./commands/slash.js";
8
- import { parseConfig } from "./config.js";
8
+ import { isDefaultApiUrl, parseConfig } from "./config.js";
9
9
  import {
10
10
  buildAgentEndCaptureHandler,
11
11
  buildBeforeResetCaptureHandler,
@@ -73,7 +73,7 @@ export default {
73
73
  commands: ["nowledge-mem"],
74
74
  });
75
75
 
76
- const remoteMode = cfg.apiUrl && cfg.apiUrl !== "http://127.0.0.1:14242";
76
+ const remoteMode = !isDefaultApiUrl(cfg.apiUrl);
77
77
  logger.info(
78
78
  `nowledge-mem: initialized (recall=${cfg.autoRecall}, capture=${cfg.autoCapture}, mode=${remoteMode ? `remote → ${cfg.apiUrl}` : "local"})`,
79
79
  );
@@ -48,7 +48,11 @@ function getNodeTitle(node) {
48
48
 
49
49
  function getNodeSnippet(node) {
50
50
  const content =
51
- node.metadata?.content || node.content || node.summary || node.description || "";
51
+ node.metadata?.content ||
52
+ node.content ||
53
+ node.summary ||
54
+ node.description ||
55
+ "";
52
56
  return content.slice(0, 150);
53
57
  }
54
58
 
@@ -75,8 +79,7 @@ function groupConnectionsByEdgeType(edges, nodeMap, centerId) {
75
79
  const groups = new Map();
76
80
 
77
81
  for (const edge of edges) {
78
- const neighborId =
79
- edge.source === centerId ? edge.target : edge.source;
82
+ const neighborId = edge.source === centerId ? edge.target : edge.source;
80
83
  const node = nodeMap.get(neighborId);
81
84
  if (!node) continue;
82
85
 
@@ -236,9 +239,12 @@ export function createConnectionsTool(client, logger) {
236
239
  // --- Other memory connections (RELATED, etc.) ---
237
240
  for (const [edgeType, conns] of grouped.entries()) {
238
241
  if (
239
- ["CRYSTALLIZED_FROM", "EVOLVES", "SOURCED_FROM", "MENTIONS"].includes(
240
- edgeType,
241
- )
242
+ [
243
+ "CRYSTALLIZED_FROM",
244
+ "EVOLVES",
245
+ "SOURCED_FROM",
246
+ "MENTIONS",
247
+ ].includes(edgeType)
242
248
  )
243
249
  continue;
244
250
 
@@ -280,14 +286,19 @@ export function createConnectionsTool(client, logger) {
280
286
  if (edges.length > 0) {
281
287
  const lines = edges.map((edge) => {
282
288
  const relation = edge.content_relation || "";
283
- const relLabel = EVOLVES_RELATION_LABELS[relation] || relation || "";
289
+ const relLabel =
290
+ EVOLVES_RELATION_LABELS[relation] || relation || "";
284
291
  // Show the "other" node relative to our target
285
292
  const isOlderNode = edge.older_id === targetId;
286
293
  const otherTitle = isOlderNode
287
- ? (edge.newer_title || "(untitled)")
288
- : (edge.older_title || "(untitled)");
294
+ ? edge.newer_title || "(untitled)"
295
+ : edge.older_title || "(untitled)";
296
+ const otherId = isOlderNode
297
+ ? edge.newer_id
298
+ : edge.older_id;
289
299
  const direction = isOlderNode ? "→" : "←";
290
- return ` ${direction} ${otherTitle}${relLabel ? ` ${relLabel}` : ""}`;
300
+ const idLine = otherId ? `\n → id: ${otherId}` : "";
301
+ return ` ${direction} ${otherTitle}${relLabel ? ` — ${relLabel}` : ""}${idLine}`;
291
302
  });
292
303
  sections.push(`Knowledge evolution:\n${lines.join("\n")}`);
293
304
  }
@@ -295,7 +306,10 @@ export function createConnectionsTool(client, logger) {
295
306
  // No EVOLVES chain for this memory — normal
296
307
  }
297
308
 
298
- if (sections.length === 0 || (sections.length === 1 && sections[0].includes("No direct connections"))) {
309
+ if (
310
+ sections.length === 0 ||
311
+ (sections.length === 1 && sections[0].includes("No direct connections"))
312
+ ) {
299
313
  return {
300
314
  content: [
301
315
  {
@@ -50,19 +50,23 @@ export function createContextTool(client, logger) {
50
50
 
51
51
  // — PATCH MODE —
52
52
  if (patchSection) {
53
- const patchContent = safeParams.patch_content !== undefined
54
- ? String(safeParams.patch_content)
55
- : undefined;
56
- const patchAppend = safeParams.patch_append !== undefined
57
- ? String(safeParams.patch_append)
58
- : undefined;
53
+ const patchContent =
54
+ safeParams.patch_content !== undefined
55
+ ? String(safeParams.patch_content)
56
+ : undefined;
57
+ const patchAppend =
58
+ safeParams.patch_append !== undefined
59
+ ? String(safeParams.patch_append)
60
+ : undefined;
59
61
 
60
62
  if (patchContent === undefined && patchAppend === undefined) {
61
63
  return {
62
- content: [{
63
- type: "text",
64
- text: "patch_section requires either patch_content (replace) or patch_append (append). Please provide one.",
65
- }],
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: "patch_section requires either patch_content (replace) or patch_append (append). Please provide one.",
68
+ },
69
+ ],
66
70
  };
67
71
  }
68
72
 
@@ -73,19 +77,23 @@ export function createContextTool(client, logger) {
73
77
  });
74
78
  const action = patchAppend !== undefined ? "Appended to" : "Replaced";
75
79
  return {
76
- content: [{
77
- type: "text",
78
- text: `Working Memory updated. ${action} section: "${patchSection}".`,
79
- }],
80
+ content: [
81
+ {
82
+ type: "text",
83
+ text: `Working Memory updated. ${action} section: "${patchSection}".`,
84
+ },
85
+ ],
80
86
  };
81
87
  } catch (err) {
82
88
  const msg = err instanceof Error ? err.message : String(err);
83
89
  logger.error(`context patch failed: ${msg}`);
84
90
  return {
85
- content: [{
86
- type: "text",
87
- text: `Failed to patch Working Memory: ${msg}`,
88
- }],
91
+ content: [
92
+ {
93
+ type: "text",
94
+ text: `Failed to patch Working Memory: ${msg}`,
95
+ },
96
+ ],
89
97
  };
90
98
  }
91
99
  }
@@ -13,14 +13,13 @@ export function createMemorySearchTool(client, logger) {
13
13
  return {
14
14
  name: "memory_search",
15
15
  description:
16
- "Search the user's knowledge graph using a multi-signal scoring pipeline: " +
17
- "semantic (embedding), BM25 keyword, label match, graph & community signals, and recency/importance decay " +
18
- "not just simple vector similarity. " +
19
- "Finds prior work, decisions, preferences, and facts. " +
20
- "Returns snippets with memoryIds. " +
21
- "Pass a memoryId to nowledge_mem_connections for cross-topic synthesis or source provenance. " +
22
- "Supports bi-temporal filtering: event_date_from/to (when the fact HAPPENED) and " +
23
- "recorded_date_from/to (when it was SAVED). Format: YYYY, YYYY-MM, or YYYY-MM-DD. " +
16
+ "Search the user's knowledge graph for prior decisions, preferences, facts, and past work. " +
17
+ "WHEN TO USE: Call this proactively when the user mentions past work, references previous decisions, " +
18
+ "asks about something they might have discussed before, or when prior context would improve your response. " +
19
+ "Don't wait to be asked — if the conversation topic might have relevant history, search for it. " +
20
+ "Uses multi-signal scoring: semantic, BM25 keyword, label, graph & community signals, recency/importance decay. " +
21
+ "Returns snippets with memoryIds — pass a memoryId to nowledge_mem_connections for cross-topic synthesis or source provenance. " +
22
+ "Supports bi-temporal filtering: event_date_from/to (when the fact HAPPENED), recorded_date_from/to (when it was SAVED). " +
24
23
  "For browsing recent activity by day use nowledge_mem_timeline instead.",
25
24
  parameters: {
26
25
  type: "object",
@@ -132,8 +131,7 @@ export function createMemorySearchTool(client, logger) {
132
131
  memoryId: entry.id,
133
132
  };
134
133
  // Scoring transparency: show which signals fired
135
- if (entry.relevanceReason)
136
- result.matchedVia = entry.relevanceReason;
134
+ if (entry.relevanceReason) result.matchedVia = entry.relevanceReason;
137
135
  // Importance context
138
136
  if (entry.importance !== undefined && entry.importance !== null)
139
137
  result.importance = Number(entry.importance);
package/src/tools/save.js CHANGED
@@ -61,7 +61,7 @@ export function createSaveTool(client, logger) {
61
61
  type: "array",
62
62
  items: { type: "string" },
63
63
  description:
64
- "Topic or project labels for this memory (e.g. [\"python\", \"infra\"]). Used in search filtering.",
64
+ 'Topic or project labels for this memory (e.g. ["python", "infra"]). Used in search filtering.',
65
65
  },
66
66
  event_start: {
67
67
  type: "string",
@@ -86,7 +86,9 @@ export function createSaveTool(client, logger) {
86
86
  async execute(_toolCallId, params) {
87
87
  const safeParams = params && typeof params === "object" ? params : {};
88
88
  const text = String(safeParams.text ?? "").trim();
89
- const title = safeParams.title ? String(safeParams.title).trim() : undefined;
89
+ const title = safeParams.title
90
+ ? String(safeParams.title).trim()
91
+ : undefined;
90
92
  const unitType =
91
93
  typeof safeParams.unit_type === "string" &&
92
94
  VALID_UNIT_TYPES.has(safeParams.unit_type)