@nowledge/openclaw-nowledge-mem 0.2.7 → 0.3.0
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 +44 -0
- package/CLAUDE.md +138 -0
- package/README.md +19 -32
- package/openclaw.plugin.json +10 -10
- package/package.json +2 -25
- package/src/client.js +81 -21
- package/src/config.js +21 -5
- package/src/hooks/capture.js +84 -92
- package/src/hooks/recall.js +19 -40
- package/src/index.js +2 -2
- package/src/tools/connections.js +25 -11
- package/src/tools/context.js +26 -18
- package/src/tools/memory-search.js +8 -10
- package/src/tools/save.js +4 -2
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://
|
|
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
|
-
##
|
|
121
|
+
## Operating Modes
|
|
127
122
|
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
### Auto-Recall (`autoRecall`, default: false)
|
|
133
132
|
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
139
|
+
**1. Thread capture (always).** The full conversation is appended to a persistent thread. Unconditional, idempotent by message ID.
|
|
153
140
|
|
|
154
|
-
**
|
|
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 | `
|
|
176
|
-
| `autoCapture` | boolean | `false` |
|
|
177
|
-
| `maxRecallResults` | integer | `5` | Max memories to recall (
|
|
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
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-nowledge-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"kind": "memory",
|
|
5
5
|
"uiHints": {
|
|
6
6
|
"autoRecall": {
|
|
7
7
|
"label": "Auto-recall at session start",
|
|
8
|
-
"help": "Inject Working Memory
|
|
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": "
|
|
12
|
+
"help": "Capture conversation threads and distill key memories via LLM at session end"
|
|
13
13
|
},
|
|
14
14
|
"maxRecallResults": {
|
|
15
15
|
"label": "Max recall results",
|
|
16
|
-
"help": "How many memories to inject for each recall cycle (1
|
|
16
|
+
"help": "How many memories to inject for each recall cycle (1\u201320)"
|
|
17
17
|
},
|
|
18
18
|
"apiUrl": {
|
|
19
19
|
"label": "Server URL (remote mode)",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"apiKey": {
|
|
23
23
|
"label": "API key (remote mode)",
|
|
24
|
-
"help": "Required when connecting to a remote server. Never logged or passed as a CLI argument
|
|
24
|
+
"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
25
|
"secret": true
|
|
26
26
|
}
|
|
27
27
|
},
|
|
@@ -30,20 +30,20 @@
|
|
|
30
30
|
"properties": {
|
|
31
31
|
"autoRecall": {
|
|
32
32
|
"type": "boolean",
|
|
33
|
-
"default":
|
|
34
|
-
"description": "Inject Working Memory and recalled memories at session start"
|
|
33
|
+
"default": false,
|
|
34
|
+
"description": "Inject Working Memory and recalled memories at session start. Off by default \u2014 the agent queries memory on demand via tools."
|
|
35
35
|
},
|
|
36
36
|
"autoCapture": {
|
|
37
37
|
"type": "boolean",
|
|
38
38
|
"default": false,
|
|
39
|
-
"description": "
|
|
39
|
+
"description": "Capture conversation threads and distill key memories via LLM at session end"
|
|
40
40
|
},
|
|
41
41
|
"maxRecallResults": {
|
|
42
42
|
"type": "integer",
|
|
43
43
|
"default": 5,
|
|
44
44
|
"minimum": 1,
|
|
45
45
|
"maximum": 20,
|
|
46
|
-
"description": "Maximum memories to recall at session start"
|
|
46
|
+
"description": "Maximum memories to recall at session start (only used when autoRecall is enabled)"
|
|
47
47
|
},
|
|
48
48
|
"apiUrl": {
|
|
49
49
|
"type": "string",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"apiKey": {
|
|
54
54
|
"type": "string",
|
|
55
55
|
"default": "",
|
|
56
|
-
"description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var
|
|
56
|
+
"description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var \u2014 never logged."
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,42 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowledge/openclaw-nowledge-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Nowledge Mem memory plugin for OpenClaw
|
|
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()
|
|
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
|
-
|
|
53
|
-
|
|
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 =
|
|
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(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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)
|
|
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)
|
|
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(
|
|
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 {
|
|
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 = [
|
|
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(
|
|
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(
|
|
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,3 +1,14 @@
|
|
|
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",
|
|
@@ -9,10 +20,15 @@ const ALLOWED_KEYS = new Set([
|
|
|
9
20
|
export function parseConfig(raw) {
|
|
10
21
|
const obj = raw && typeof raw === "object" ? raw : {};
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
// Strict: reject unknown keys so users catch typos immediately.
|
|
24
|
+
// If OpenClaw adds new platform-level keys that should pass through,
|
|
25
|
+
// add them to ALLOWED_KEYS rather than silently accepting anything.
|
|
26
|
+
const unknownKeys = Object.keys(obj).filter((k) => !ALLOWED_KEYS.has(k));
|
|
27
|
+
if (unknownKeys.length > 0) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}. ` +
|
|
30
|
+
`Allowed keys: ${[...ALLOWED_KEYS].join(", ")}`,
|
|
31
|
+
);
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
// apiUrl: config wins, then env var, then local default
|
|
@@ -30,7 +46,7 @@ export function parseConfig(raw) {
|
|
|
30
46
|
"";
|
|
31
47
|
|
|
32
48
|
return {
|
|
33
|
-
autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall :
|
|
49
|
+
autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall : false,
|
|
34
50
|
autoCapture: typeof obj.autoCapture === "boolean" ? obj.autoCapture : false,
|
|
35
51
|
maxRecallResults:
|
|
36
52
|
typeof obj.maxRecallResults === "number" &&
|
package/src/hooks/capture.js
CHANGED
|
@@ -2,6 +2,9 @@ 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;
|
|
5
8
|
|
|
6
9
|
function truncate(text, max = MAX_MESSAGE_CHARS) {
|
|
7
10
|
const str = String(text || "").trim();
|
|
@@ -61,73 +64,12 @@ function normalizeRoleMessage(raw) {
|
|
|
61
64
|
return {
|
|
62
65
|
role,
|
|
63
66
|
content: truncate(text),
|
|
67
|
+
fullContent: text,
|
|
64
68
|
timestamp,
|
|
65
69
|
externalHint,
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
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
73
|
function buildThreadTitle(ctx, reason) {
|
|
132
74
|
const session = ctx?.sessionKey || ctx?.sessionId || "session";
|
|
133
75
|
const reasonSuffix = reason ? ` (${reason})` : "";
|
|
@@ -250,12 +192,12 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
|
|
|
250
192
|
logger.info(
|
|
251
193
|
`capture: appended ${appended.messagesAdded} messages to ${threadId} (${reason || "event"})`,
|
|
252
194
|
);
|
|
253
|
-
return;
|
|
195
|
+
return { threadId, normalized };
|
|
254
196
|
} catch (err) {
|
|
255
197
|
if (!client.isThreadNotFoundError(err)) {
|
|
256
198
|
const message = err instanceof Error ? err.message : String(err);
|
|
257
199
|
logger.warn(`capture: thread append failed for ${threadId}: ${message}`);
|
|
258
|
-
return;
|
|
200
|
+
return null;
|
|
259
201
|
}
|
|
260
202
|
}
|
|
261
203
|
|
|
@@ -269,32 +211,56 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
|
|
|
269
211
|
logger.info(
|
|
270
212
|
`capture: created thread ${createdId} with ${messages.length} messages (${reason || "event"})`,
|
|
271
213
|
);
|
|
214
|
+
return { threadId, normalized };
|
|
272
215
|
} catch (err) {
|
|
273
216
|
const message = err instanceof Error ? err.message : String(err);
|
|
274
217
|
logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
|
|
218
|
+
return null;
|
|
275
219
|
}
|
|
276
220
|
}
|
|
277
221
|
|
|
278
222
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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.
|
|
223
|
+
* Build a plain-text conversation string from normalized messages.
|
|
224
|
+
* Used as input for triage and distillation.
|
|
286
225
|
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
226
|
+
* Per-message content is capped at MAX_DISTILL_MESSAGE_CHARS and the
|
|
227
|
+
* total output at MAX_CONVERSATION_CHARS to keep LLM API payloads
|
|
228
|
+
* bounded — long coding sessions with large code blocks can produce
|
|
229
|
+
* arbitrarily large fullContent.
|
|
289
230
|
*/
|
|
290
|
-
|
|
291
|
-
const
|
|
231
|
+
function buildConversationText(normalized) {
|
|
232
|
+
const parts = [];
|
|
233
|
+
let total = 0;
|
|
234
|
+
for (const m of normalized) {
|
|
235
|
+
const text = truncate(m.fullContent || m.content, MAX_DISTILL_MESSAGE_CHARS);
|
|
236
|
+
const line = `${m.role}: ${text}`;
|
|
237
|
+
if (total + line.length > MAX_CONVERSATION_CHARS) break;
|
|
238
|
+
parts.push(line);
|
|
239
|
+
total += line.length + 2; // account for "\n\n" separator
|
|
240
|
+
}
|
|
241
|
+
return parts.join("\n\n");
|
|
242
|
+
}
|
|
292
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Capture thread + LLM-based distillation after a successful agent run.
|
|
246
|
+
*
|
|
247
|
+
* Two independent operations (agent_end only):
|
|
248
|
+
* 1. Thread append: always attempted (unconditional, idempotent).
|
|
249
|
+
* 2. Triage + distill: only if enough messages AND cheap LLM triage
|
|
250
|
+
* determines the conversation has save-worthy content. This replaces
|
|
251
|
+
* the old English-only regex heuristic with language-agnostic LLM
|
|
252
|
+
* classification.
|
|
253
|
+
*
|
|
254
|
+
* Note: LLM distillation (step 2) runs exclusively in this agent_end handler.
|
|
255
|
+
* The before_reset / after_compaction handlers only capture threads — no
|
|
256
|
+
* triage or distillation, since those are mid-session checkpoints.
|
|
257
|
+
*/
|
|
258
|
+
export function buildAgentEndCaptureHandler(client, cfg, logger) {
|
|
293
259
|
return async (event, ctx) => {
|
|
294
260
|
if (!event?.success) return;
|
|
295
261
|
|
|
296
|
-
// 1. Always thread-append
|
|
297
|
-
await appendOrCreateThread({
|
|
262
|
+
// 1. Always thread-append (idempotent, self-guards on empty messages).
|
|
263
|
+
const result = await appendOrCreateThread({
|
|
298
264
|
client,
|
|
299
265
|
logger,
|
|
300
266
|
event,
|
|
@@ -302,32 +268,58 @@ export function buildAgentEndCaptureHandler(client, _cfg, logger) {
|
|
|
302
268
|
reason: "agent_end",
|
|
303
269
|
});
|
|
304
270
|
|
|
305
|
-
// 2.
|
|
306
|
-
//
|
|
307
|
-
if
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
271
|
+
// 2. Triage + distill: language-agnostic LLM-based capture.
|
|
272
|
+
// Defensive guard — registration in index.js already gates on autoCapture,
|
|
273
|
+
// but check here too so the handler is safe if called directly.
|
|
274
|
+
if (!cfg.autoCapture) return;
|
|
275
|
+
|
|
276
|
+
// Skip short conversations — not worth the triage cost.
|
|
277
|
+
if (
|
|
278
|
+
!result ||
|
|
279
|
+
!result.normalized ||
|
|
280
|
+
result.normalized.length < MIN_MESSAGES_FOR_DISTILL
|
|
281
|
+
) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
311
284
|
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
const previousFp = seenBySession.get(sessionKey);
|
|
315
|
-
if (previousFp === nextFp) return;
|
|
316
|
-
seenBySession.set(sessionKey, nextFp);
|
|
285
|
+
const conversationText = buildConversationText(result.normalized);
|
|
286
|
+
if (conversationText.length < 100) return;
|
|
317
287
|
|
|
318
288
|
try {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
289
|
+
const triage = await client.triageConversation(conversationText);
|
|
290
|
+
if (!triage?.should_distill) {
|
|
291
|
+
logger.debug?.(
|
|
292
|
+
`capture: triage skipped distillation — ${triage?.reason || "no reason"}`,
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
logger.info(`capture: triage passed — ${triage.reason}`);
|
|
298
|
+
|
|
299
|
+
const distillResult = await client.distillThread({
|
|
300
|
+
threadId: result.threadId,
|
|
301
|
+
title: buildThreadTitle(ctx, "distilled"),
|
|
302
|
+
content: conversationText,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const count =
|
|
306
|
+
distillResult?.memories_created ??
|
|
307
|
+
distillResult?.created_memories?.length ??
|
|
308
|
+
0;
|
|
309
|
+
logger.info(
|
|
310
|
+
`capture: distilled ${count} memories from ${result.threadId}`,
|
|
311
|
+
);
|
|
322
312
|
} catch (err) {
|
|
323
313
|
const message = err instanceof Error ? err.message : String(err);
|
|
324
|
-
logger.warn(`capture:
|
|
314
|
+
logger.warn(`capture: triage/distill failed: ${message}`);
|
|
315
|
+
// Not fatal — thread is already captured above.
|
|
325
316
|
}
|
|
326
317
|
};
|
|
327
318
|
}
|
|
328
319
|
|
|
329
320
|
/**
|
|
330
321
|
* Capture thread messages before reset or after compaction.
|
|
322
|
+
* Thread-only (no distillation) — these are lifecycle checkpoints.
|
|
331
323
|
*/
|
|
332
324
|
export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
|
|
333
325
|
return async (event, ctx) => {
|
package/src/hooks/recall.js
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
);
|
package/src/tools/connections.js
CHANGED
|
@@ -48,7 +48,11 @@ function getNodeTitle(node) {
|
|
|
48
48
|
|
|
49
49
|
function getNodeSnippet(node) {
|
|
50
50
|
const content =
|
|
51
|
-
node.metadata?.content ||
|
|
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
|
-
[
|
|
240
|
-
|
|
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 =
|
|
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
|
-
?
|
|
288
|
-
:
|
|
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
|
-
|
|
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 (
|
|
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
|
{
|
package/src/tools/context.js
CHANGED
|
@@ -50,19 +50,23 @@ export function createContextTool(client, logger) {
|
|
|
50
50
|
|
|
51
51
|
// — PATCH MODE —
|
|
52
52
|
if (patchSection) {
|
|
53
|
-
const patchContent =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"Supports bi-temporal filtering: event_date_from/to (when the fact HAPPENED)
|
|
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
|
-
|
|
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
|
|
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)
|