@nowledge/openclaw-nowledge-mem 0.2.7
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 +211 -0
- package/README.md +191 -0
- package/openclaw.plugin.json +61 -0
- package/package.json +54 -0
- package/src/client.js +673 -0
- package/src/commands/cli.js +45 -0
- package/src/commands/slash.js +109 -0
- package/src/config.js +43 -0
- package/src/hooks/capture.js +337 -0
- package/src/hooks/recall.js +109 -0
- package/src/index.js +81 -0
- package/src/tools/connections.js +324 -0
- package/src/tools/context.js +126 -0
- package/src/tools/forget.js +154 -0
- package/src/tools/memory-get.js +168 -0
- package/src/tools/memory-search.js +183 -0
- package/src/tools/save.js +179 -0
- package/src/tools/timeline.js +208 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Patch a single markdown section in a Working Memory document.
|
|
5
|
+
* Returns the updated document string, or null if heading not found.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} currentContent Full WM markdown content
|
|
8
|
+
* @param {string} heading Partial or full heading to match (case-insensitive)
|
|
9
|
+
* @param {{ content?: string; append?: string }} options
|
|
10
|
+
*/
|
|
11
|
+
function patchWmSection(currentContent, heading, { content, append } = {}) {
|
|
12
|
+
const lines = currentContent.split("\n");
|
|
13
|
+
const headingLc = heading.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
// Infer section level from the heading prefix (## = 2, ### = 3, etc.)
|
|
16
|
+
const levelMatch = headingLc.match(/^(#{1,6})\s/);
|
|
17
|
+
const targetLevel = levelMatch ? levelMatch[1].length : 2;
|
|
18
|
+
|
|
19
|
+
// Find the start of the section
|
|
20
|
+
let startIdx = -1;
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
if (lines[i].trim().toLowerCase().includes(headingLc)) {
|
|
23
|
+
startIdx = i;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (startIdx === -1) return null;
|
|
28
|
+
|
|
29
|
+
// Find end of section (next heading at same or higher level)
|
|
30
|
+
let endIdx = lines.length;
|
|
31
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
32
|
+
const m = lines[i].match(/^(#{1,6})\s/);
|
|
33
|
+
if (m && m[1].length <= targetLevel) {
|
|
34
|
+
endIdx = i;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const headingLine = lines[startIdx];
|
|
40
|
+
const bodyLines = lines.slice(startIdx + 1, endIdx);
|
|
41
|
+
|
|
42
|
+
let newBody;
|
|
43
|
+
if (append !== undefined) {
|
|
44
|
+
const existing = bodyLines.join("\n").trimEnd();
|
|
45
|
+
newBody = `${existing}\n${append.trim()}`;
|
|
46
|
+
} else {
|
|
47
|
+
newBody = (content ?? "").trimEnd();
|
|
48
|
+
}
|
|
49
|
+
|
|
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");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Nowledge Mem client. Wraps the nmem CLI for local-first and remote operations.
|
|
60
|
+
*
|
|
61
|
+
* All operations go through the CLI first. This means:
|
|
62
|
+
* - Local mode: CLI uses http://127.0.0.1:14242 automatically
|
|
63
|
+
* - Remote mode: configure via apiUrl + apiKey (plugin config or env vars)
|
|
64
|
+
* (see: https://docs.nowledge.co/docs/remote-access)
|
|
65
|
+
*
|
|
66
|
+
* Falls back to direct API calls when a CLI command is too new for the installed
|
|
67
|
+
* version. The fallback path uses the same apiUrl / apiKey.
|
|
68
|
+
*
|
|
69
|
+
* Credential rules:
|
|
70
|
+
* - apiUrl: passed to CLI via --api-url flag (not a secret)
|
|
71
|
+
* - apiKey: injected into the child process env as NMEM_API_KEY ONLY
|
|
72
|
+
* (never passed as a CLI arg to avoid exposure in `ps aux`)
|
|
73
|
+
* (never logged, even at debug level)
|
|
74
|
+
*/
|
|
75
|
+
export class NowledgeMemClient {
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} logger
|
|
78
|
+
* @param {{ apiUrl?: string; apiKey?: string }} [credentials]
|
|
79
|
+
*/
|
|
80
|
+
constructor(logger, credentials = {}) {
|
|
81
|
+
this.logger = logger;
|
|
82
|
+
this.nmemCmd = null;
|
|
83
|
+
// 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";
|
|
85
|
+
this._apiKey = (credentials.apiKey || "").trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── API helpers (fallback path and direct operations) ─────────────────────
|
|
89
|
+
|
|
90
|
+
getApiBaseUrl() {
|
|
91
|
+
return this._apiUrl;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getApiHeaders() {
|
|
95
|
+
const headers = { "content-type": "application/json" };
|
|
96
|
+
if (this._apiKey) {
|
|
97
|
+
headers.authorization = `Bearer ${this._apiKey}`;
|
|
98
|
+
headers["x-nmem-api-key"] = this._apiKey;
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async apiJson(method, path, body, timeout = 30_000) {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
106
|
+
const url = `${this.getApiBaseUrl()}${path}`;
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
method,
|
|
110
|
+
headers: this.getApiHeaders(),
|
|
111
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const text = await response.text();
|
|
116
|
+
let data = {};
|
|
117
|
+
try {
|
|
118
|
+
data = text ? JSON.parse(text) : {};
|
|
119
|
+
} catch {
|
|
120
|
+
data = { raw: text };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
const detail =
|
|
125
|
+
typeof data?.detail === "string"
|
|
126
|
+
? data.detail
|
|
127
|
+
: typeof data?.message === "string"
|
|
128
|
+
? data.message
|
|
129
|
+
: typeof data?.raw === "string"
|
|
130
|
+
? data.raw
|
|
131
|
+
: `HTTP ${response.status}`;
|
|
132
|
+
throw new Error(detail);
|
|
133
|
+
}
|
|
134
|
+
return data;
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── CLI helpers ────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
resolveCommand() {
|
|
143
|
+
if (this.nmemCmd) return this.nmemCmd;
|
|
144
|
+
|
|
145
|
+
const candidates = [["nmem"], ["uvx", "--from", "nmem-cli", "nmem"]];
|
|
146
|
+
|
|
147
|
+
for (const cmd of candidates) {
|
|
148
|
+
const [bin, ...baseArgs] = cmd;
|
|
149
|
+
const result = spawnSync(bin, [...baseArgs, "--version"], {
|
|
150
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
+
timeout: 10_000,
|
|
152
|
+
encoding: "utf-8",
|
|
153
|
+
});
|
|
154
|
+
if (result.status === 0) {
|
|
155
|
+
this.nmemCmd = cmd;
|
|
156
|
+
this.logger.info(`nmem resolved: ${cmd.join(" ")}`);
|
|
157
|
+
return cmd;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new Error(
|
|
162
|
+
"nmem CLI not found. Install with: pip install nmem-cli (or use uvx)",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the env for child process spawns.
|
|
168
|
+
* apiKey is injected here — NEVER via CLI args.
|
|
169
|
+
*/
|
|
170
|
+
_spawnEnv() {
|
|
171
|
+
const env = { ...process.env };
|
|
172
|
+
// Explicit config wins over any existing env var
|
|
173
|
+
if (this._apiUrl !== "http://127.0.0.1:14242") {
|
|
174
|
+
env.NMEM_API_URL = this._apiUrl;
|
|
175
|
+
}
|
|
176
|
+
if (this._apiKey) {
|
|
177
|
+
env.NMEM_API_KEY = this._apiKey;
|
|
178
|
+
}
|
|
179
|
+
return env;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build base CLI args. --api-url is safe to pass as a flag (not a secret).
|
|
184
|
+
* The key is NEVER added here — it goes in env only.
|
|
185
|
+
*/
|
|
186
|
+
_apiUrlArgs() {
|
|
187
|
+
return this._apiUrl !== "http://127.0.0.1:14242"
|
|
188
|
+
? ["--api-url", this._apiUrl]
|
|
189
|
+
: [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
exec(args, timeout = 30_000) {
|
|
193
|
+
const cmd = this.resolveCommand();
|
|
194
|
+
const [bin, ...baseArgs] = cmd;
|
|
195
|
+
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
|
+
});
|
|
202
|
+
if (result.error) {
|
|
203
|
+
throw result.error;
|
|
204
|
+
}
|
|
205
|
+
if (result.status !== 0) {
|
|
206
|
+
const message = (result.stderr || result.stdout || "").trim();
|
|
207
|
+
throw new Error(
|
|
208
|
+
message || `nmem exited with code ${String(result.status)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return String(result.stdout ?? "").trim();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
214
|
+
this.logger.error(
|
|
215
|
+
`nmem command failed: ${cmd.join(" ")} ${args.join(" ")} — ${message}`,
|
|
216
|
+
);
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
execJson(args, timeout = 30_000) {
|
|
222
|
+
const raw = this.exec(args, timeout);
|
|
223
|
+
return JSON.parse(raw);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Search ────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Search memories via CLI. Returns rich metadata: relevance_reason,
|
|
230
|
+
* importance, labels, and temporal fields (added in CLI v0.4+).
|
|
231
|
+
*/
|
|
232
|
+
async search(query, limit = 5) {
|
|
233
|
+
const normalizedLimit = Math.min(
|
|
234
|
+
100,
|
|
235
|
+
Math.max(1, Math.trunc(Number(limit) || 5)),
|
|
236
|
+
);
|
|
237
|
+
const data = this.execJson([
|
|
238
|
+
"--json",
|
|
239
|
+
"m",
|
|
240
|
+
"search",
|
|
241
|
+
String(query),
|
|
242
|
+
"-n",
|
|
243
|
+
String(normalizedLimit),
|
|
244
|
+
]);
|
|
245
|
+
const memories = data.memories ?? data.results ?? [];
|
|
246
|
+
return memories.map((m) => this._normalizeMemory(m));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* searchRich — same as search(). The CLI now returns all rich fields
|
|
251
|
+
* (relevance_reason, importance, labels, temporal) natively.
|
|
252
|
+
*/
|
|
253
|
+
async searchRich(query, limit = 5) {
|
|
254
|
+
return this.search(query, limit);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Bi-temporal search — filters by when the fact happened (event_date_*)
|
|
259
|
+
* or when it was saved (recorded_date_*).
|
|
260
|
+
*
|
|
261
|
+
* Uses CLI with --event-from/--event-to/--recorded-from/--recorded-to.
|
|
262
|
+
* Falls back to API if CLI is older than the bi-temporal update.
|
|
263
|
+
*/
|
|
264
|
+
async searchTemporal(
|
|
265
|
+
query,
|
|
266
|
+
{
|
|
267
|
+
limit = 10,
|
|
268
|
+
eventDateFrom,
|
|
269
|
+
eventDateTo,
|
|
270
|
+
recordedDateFrom,
|
|
271
|
+
recordedDateTo,
|
|
272
|
+
} = {},
|
|
273
|
+
) {
|
|
274
|
+
const normalizedLimit = Math.min(
|
|
275
|
+
100,
|
|
276
|
+
Math.max(1, Math.trunc(Number(limit) || 10)),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Build CLI args
|
|
280
|
+
const args = [
|
|
281
|
+
"--json",
|
|
282
|
+
"m",
|
|
283
|
+
"search",
|
|
284
|
+
String(query || ""),
|
|
285
|
+
"-n",
|
|
286
|
+
String(normalizedLimit),
|
|
287
|
+
];
|
|
288
|
+
if (eventDateFrom) args.push("--event-from", String(eventDateFrom));
|
|
289
|
+
if (eventDateTo) args.push("--event-to", String(eventDateTo));
|
|
290
|
+
if (recordedDateFrom) args.push("--recorded-from", String(recordedDateFrom));
|
|
291
|
+
if (recordedDateTo) args.push("--recorded-to", String(recordedDateTo));
|
|
292
|
+
|
|
293
|
+
let data;
|
|
294
|
+
try {
|
|
295
|
+
data = this.execJson(args);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// Graceful fallback: CLI doesn't know --event-from yet → use API directly
|
|
298
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
299
|
+
const needsFallback =
|
|
300
|
+
message.includes("unrecognized arguments") ||
|
|
301
|
+
message.includes("invalid choice");
|
|
302
|
+
if (!needsFallback) throw err;
|
|
303
|
+
|
|
304
|
+
this.logger.warn("searchTemporal: CLI too old, falling back to API");
|
|
305
|
+
const qs = new URLSearchParams({ limit: String(normalizedLimit) });
|
|
306
|
+
if (query) qs.set("q", String(query));
|
|
307
|
+
if (eventDateFrom) qs.set("event_date_from", String(eventDateFrom));
|
|
308
|
+
if (eventDateTo) qs.set("event_date_to", String(eventDateTo));
|
|
309
|
+
if (recordedDateFrom) qs.set("recorded_date_from", String(recordedDateFrom));
|
|
310
|
+
if (recordedDateTo) qs.set("recorded_date_to", String(recordedDateTo));
|
|
311
|
+
const apiData = await this.apiJson("GET", `/memories/search?${qs.toString()}`);
|
|
312
|
+
// Normalize from API response format
|
|
313
|
+
const apiMems = (apiData.memories ?? []).map((m) => ({
|
|
314
|
+
id: String(m.id ?? ""),
|
|
315
|
+
title: String(m.title ?? ""),
|
|
316
|
+
content: String(m.content ?? ""),
|
|
317
|
+
score: Number(m.confidence ?? m.metadata?.similarity_score ?? 0),
|
|
318
|
+
importance: Number(m.metadata?.importance ?? 0.5),
|
|
319
|
+
relevance_reason: m.metadata?.relevance_reason ?? null,
|
|
320
|
+
labels: m.label_ids ?? [],
|
|
321
|
+
event_start: m.metadata?.event_start ?? null,
|
|
322
|
+
event_end: m.metadata?.event_end ?? null,
|
|
323
|
+
temporal_context: m.metadata?.temporal_context ?? null,
|
|
324
|
+
}));
|
|
325
|
+
return { memories: apiMems.map((m) => this._normalizeMemory(m)), searchMetadata: apiData.search_metadata ?? {} };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const memories = data.memories ?? data.results ?? [];
|
|
329
|
+
return {
|
|
330
|
+
memories: memories.map((m) => this._normalizeMemory(m)),
|
|
331
|
+
searchMetadata: {},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Normalize a memory object from either CLI or API response format.
|
|
337
|
+
* Output shape is canonical across all search paths.
|
|
338
|
+
*/
|
|
339
|
+
_normalizeMemory(m) {
|
|
340
|
+
return {
|
|
341
|
+
id: String(m.id ?? ""),
|
|
342
|
+
title: String(m.title ?? ""),
|
|
343
|
+
content: String(m.content ?? ""),
|
|
344
|
+
score: Number(m.score ?? m.confidence ?? 0),
|
|
345
|
+
importance: Number(m.importance ?? 0.5),
|
|
346
|
+
relevanceReason: m.relevance_reason ?? null,
|
|
347
|
+
labels: Array.isArray(m.labels) ? m.labels : (m.label_ids ?? []),
|
|
348
|
+
eventStart: m.event_start ?? null,
|
|
349
|
+
eventEnd: m.event_end ?? null,
|
|
350
|
+
temporalContext: m.temporal_context ?? null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Graph ─────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Expand the knowledge graph around a memory.
|
|
358
|
+
* Uses `nmem g expand <id>` (CLI v0.4+), falls back to API.
|
|
359
|
+
*/
|
|
360
|
+
async graphExpand(memoryId, { depth = 1, limit = 20 } = {}) {
|
|
361
|
+
const args = [
|
|
362
|
+
"--json",
|
|
363
|
+
"g",
|
|
364
|
+
"expand",
|
|
365
|
+
String(memoryId),
|
|
366
|
+
"--depth",
|
|
367
|
+
String(depth),
|
|
368
|
+
"-n",
|
|
369
|
+
String(limit),
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
return this.execJson(args);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
376
|
+
const needsFallback =
|
|
377
|
+
message.includes("unrecognized arguments") ||
|
|
378
|
+
message.includes("invalid choice") ||
|
|
379
|
+
message.includes("argument command: invalid choice");
|
|
380
|
+
if (!needsFallback) throw err;
|
|
381
|
+
|
|
382
|
+
this.logger.warn("graphExpand: CLI too old, falling back to API");
|
|
383
|
+
return this.apiJson(
|
|
384
|
+
"GET",
|
|
385
|
+
`/graph/expand/${encodeURIComponent(memoryId)}?depth=${depth}&limit=${limit}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get the EVOLVES version chain for a memory.
|
|
392
|
+
* Uses `nmem g evolves <id>` (CLI v0.4.1+), falls back to API.
|
|
393
|
+
*
|
|
394
|
+
* Returns edges where the memory appears as older or newer node,
|
|
395
|
+
* with relation type: replaces | enriches | confirms | challenges.
|
|
396
|
+
*/
|
|
397
|
+
async graphEvolves(memoryId, { limit = 20 } = {}) {
|
|
398
|
+
const args = ["--json", "g", "evolves", String(memoryId), "-n", String(limit)];
|
|
399
|
+
try {
|
|
400
|
+
return this.execJson(args);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
403
|
+
const needsFallback =
|
|
404
|
+
message.includes("unrecognized arguments") ||
|
|
405
|
+
message.includes("invalid choice") ||
|
|
406
|
+
message.includes("argument command: invalid choice");
|
|
407
|
+
if (!needsFallback) throw err;
|
|
408
|
+
|
|
409
|
+
this.logger.warn("graphEvolves: CLI too old, falling back to API");
|
|
410
|
+
const qs = new URLSearchParams({
|
|
411
|
+
memory_id: String(memoryId),
|
|
412
|
+
limit: String(limit),
|
|
413
|
+
});
|
|
414
|
+
return this.apiJson("GET", `/agent/evolves?${qs.toString()}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Feed ──────────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Fetch recent activity from the feed.
|
|
422
|
+
* Uses `nmem f` (CLI v0.4+), falls back to API.
|
|
423
|
+
*
|
|
424
|
+
* Date filtering (exact range):
|
|
425
|
+
* dateFrom / dateTo: YYYY-MM-DD — when events were recorded.
|
|
426
|
+
* Answers "what was I working on last Tuesday?" precisely.
|
|
427
|
+
*/
|
|
428
|
+
async feedEvents({
|
|
429
|
+
lastNDays = 7,
|
|
430
|
+
eventType,
|
|
431
|
+
tier1Only = true,
|
|
432
|
+
limit = 100,
|
|
433
|
+
dateFrom,
|
|
434
|
+
dateTo,
|
|
435
|
+
} = {}) {
|
|
436
|
+
const args = [
|
|
437
|
+
"--json",
|
|
438
|
+
"f",
|
|
439
|
+
"--days",
|
|
440
|
+
String(lastNDays),
|
|
441
|
+
"-n",
|
|
442
|
+
String(limit),
|
|
443
|
+
];
|
|
444
|
+
if (eventType) args.push("--type", String(eventType));
|
|
445
|
+
if (!tier1Only) args.push("--all");
|
|
446
|
+
if (dateFrom) args.push("--from", String(dateFrom));
|
|
447
|
+
if (dateTo) args.push("--to", String(dateTo));
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const data = this.execJson(args);
|
|
451
|
+
return Array.isArray(data) ? data : (data.events ?? []);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
454
|
+
const needsFallback =
|
|
455
|
+
message.includes("unrecognized arguments") ||
|
|
456
|
+
message.includes("invalid choice") ||
|
|
457
|
+
message.includes("argument command: invalid choice");
|
|
458
|
+
if (!needsFallback) throw err;
|
|
459
|
+
|
|
460
|
+
this.logger.warn("feedEvents: CLI too old, falling back to API");
|
|
461
|
+
const qs = new URLSearchParams({
|
|
462
|
+
last_n_days: String(lastNDays),
|
|
463
|
+
limit: String(limit),
|
|
464
|
+
});
|
|
465
|
+
if (eventType) qs.set("event_type", eventType);
|
|
466
|
+
if (dateFrom) qs.set("date_from", String(dateFrom));
|
|
467
|
+
if (dateTo) qs.set("date_to", String(dateTo));
|
|
468
|
+
const data = await this.apiJson("GET", `/agent/feed/events?${qs.toString()}`);
|
|
469
|
+
return Array.isArray(data) ? data : (data.events ?? []);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Memory CRUD ───────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
async addMemory(content, title, importance) {
|
|
476
|
+
const args = ["--json", "m", "add", String(content)];
|
|
477
|
+
if (title) args.push("-t", String(title));
|
|
478
|
+
if (importance !== undefined && Number.isFinite(Number(importance))) {
|
|
479
|
+
args.push("-i", String(importance));
|
|
480
|
+
}
|
|
481
|
+
const data = this.execJson(args);
|
|
482
|
+
return String(data.id ?? data.memory?.id ?? data.memory_id ?? "created");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async createThread({ threadId, title, messages, source = "openclaw" }) {
|
|
486
|
+
const normalizedTitle = String(title || "").trim();
|
|
487
|
+
if (!normalizedTitle) {
|
|
488
|
+
throw new Error("createThread requires a non-empty title");
|
|
489
|
+
}
|
|
490
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
491
|
+
throw new Error("createThread requires at least one message");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let data;
|
|
495
|
+
try {
|
|
496
|
+
const args = [
|
|
497
|
+
"--json",
|
|
498
|
+
"t",
|
|
499
|
+
"create",
|
|
500
|
+
"-t",
|
|
501
|
+
normalizedTitle,
|
|
502
|
+
"-m",
|
|
503
|
+
JSON.stringify(messages),
|
|
504
|
+
"-s",
|
|
505
|
+
String(source),
|
|
506
|
+
];
|
|
507
|
+
if (threadId) {
|
|
508
|
+
args.push("--id", String(threadId));
|
|
509
|
+
}
|
|
510
|
+
data = this.execJson(args);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
513
|
+
const needsApiFallback =
|
|
514
|
+
Boolean(threadId) &&
|
|
515
|
+
(message.includes("unrecognized arguments: --id") ||
|
|
516
|
+
message.includes("invalid choice"));
|
|
517
|
+
if (!needsApiFallback) {
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
this.logger.warn(
|
|
521
|
+
"createThread: CLI missing --id support, falling back to API",
|
|
522
|
+
);
|
|
523
|
+
data = await this.apiJson("POST", "/threads", {
|
|
524
|
+
thread_id: String(threadId),
|
|
525
|
+
title: normalizedTitle,
|
|
526
|
+
source: String(source),
|
|
527
|
+
messages,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return String(
|
|
532
|
+
data.id ?? data.thread?.thread_id ?? data.thread_id ?? "created",
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async appendThread({
|
|
537
|
+
threadId,
|
|
538
|
+
messages,
|
|
539
|
+
deduplicate = true,
|
|
540
|
+
idempotencyKey,
|
|
541
|
+
}) {
|
|
542
|
+
const normalizedThreadId = String(threadId || "").trim();
|
|
543
|
+
if (!normalizedThreadId) {
|
|
544
|
+
throw new Error("appendThread requires threadId");
|
|
545
|
+
}
|
|
546
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
547
|
+
return { messagesAdded: 0, totalMessages: 0 };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
const args = [
|
|
552
|
+
"--json",
|
|
553
|
+
"t",
|
|
554
|
+
"append",
|
|
555
|
+
normalizedThreadId,
|
|
556
|
+
"-m",
|
|
557
|
+
JSON.stringify(messages),
|
|
558
|
+
...(deduplicate ? [] : ["--no-deduplicate"]),
|
|
559
|
+
];
|
|
560
|
+
if (idempotencyKey) {
|
|
561
|
+
args.push("--idempotency-key", String(idempotencyKey));
|
|
562
|
+
}
|
|
563
|
+
const data = this.execJson(args);
|
|
564
|
+
return {
|
|
565
|
+
messagesAdded: Number(data.messages_added ?? 0),
|
|
566
|
+
totalMessages: Number(data.total_messages ?? 0),
|
|
567
|
+
};
|
|
568
|
+
} catch (err) {
|
|
569
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
570
|
+
const needsApiFallback =
|
|
571
|
+
message.includes("invalid choice") ||
|
|
572
|
+
message.includes("unrecognized arguments");
|
|
573
|
+
if (!needsApiFallback) {
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
576
|
+
this.logger.warn(
|
|
577
|
+
"appendThread: CLI missing append support, falling back to API",
|
|
578
|
+
);
|
|
579
|
+
const data = await this.apiJson(
|
|
580
|
+
"POST",
|
|
581
|
+
`/threads/${encodeURIComponent(normalizedThreadId)}/append`,
|
|
582
|
+
{
|
|
583
|
+
messages,
|
|
584
|
+
deduplicate,
|
|
585
|
+
...(idempotencyKey
|
|
586
|
+
? { idempotency_key: String(idempotencyKey) }
|
|
587
|
+
: {}),
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
return {
|
|
591
|
+
messagesAdded: Number(data.messages_added ?? 0),
|
|
592
|
+
totalMessages: Number(data.total_messages ?? 0),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async getMemory(memoryId) {
|
|
598
|
+
const id = String(memoryId || "").trim();
|
|
599
|
+
if (!id) {
|
|
600
|
+
throw new Error("getMemory requires memoryId");
|
|
601
|
+
}
|
|
602
|
+
return this.execJson(["--json", "m", "show", id]);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
isThreadNotFoundError(err) {
|
|
606
|
+
const message = (
|
|
607
|
+
err instanceof Error ? err.message : String(err)
|
|
608
|
+
).toLowerCase();
|
|
609
|
+
return (
|
|
610
|
+
message.includes("thread not found") ||
|
|
611
|
+
message.includes("404") ||
|
|
612
|
+
message.includes("not found")
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async readWorkingMemory() {
|
|
617
|
+
try {
|
|
618
|
+
const data = this.execJson(["--json", "wm", "read"], 15_000);
|
|
619
|
+
const content = String(data.content ?? "").trim();
|
|
620
|
+
const exists = Boolean(data.exists);
|
|
621
|
+
return { content, available: exists && content.length > 0 };
|
|
622
|
+
} catch {
|
|
623
|
+
return { content: "", available: false };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Patch a single section of Working Memory without touching the rest.
|
|
629
|
+
* Uses `nmem wm patch` (CLI v0.4.1+) — client-side read-modify-write.
|
|
630
|
+
*
|
|
631
|
+
* @param {string} heading Section heading to target (e.g. "## Focus Areas")
|
|
632
|
+
* @param {object} options
|
|
633
|
+
* @param {string} [options.content] Replace the section body
|
|
634
|
+
* @param {string} [options.append] Append text to the section body
|
|
635
|
+
*/
|
|
636
|
+
async patchWorkingMemory(heading, { content, append } = {}) {
|
|
637
|
+
const args = ["--json", "wm", "patch", "--heading", String(heading)];
|
|
638
|
+
if (content !== undefined) args.push("--content", String(content));
|
|
639
|
+
if (append !== undefined) args.push("--append", String(append));
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
return this.execJson(args, 20_000);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
645
|
+
const notSupported =
|
|
646
|
+
message.includes("unrecognized arguments") ||
|
|
647
|
+
message.includes("invalid choice");
|
|
648
|
+
if (!notSupported) throw err;
|
|
649
|
+
|
|
650
|
+
// Fallback: full-document write (read → patch inline → write)
|
|
651
|
+
this.logger.warn("patchWorkingMemory: CLI too old, falling back to full replace");
|
|
652
|
+
const current = await this.readWorkingMemory();
|
|
653
|
+
if (!current.available) throw new Error("Working Memory not available");
|
|
654
|
+
|
|
655
|
+
const updated = patchWmSection(current.content, heading, {
|
|
656
|
+
content,
|
|
657
|
+
append,
|
|
658
|
+
});
|
|
659
|
+
if (updated === null) throw new Error(`Section not found: ${heading}`);
|
|
660
|
+
|
|
661
|
+
return this.apiJson("PUT", "/agent/working-memory", { content: updated });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async checkHealth() {
|
|
666
|
+
try {
|
|
667
|
+
this.exec(["status"]);
|
|
668
|
+
return true;
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function createCliRegistrar(client, logger) {
|
|
2
|
+
return ({ program }) => {
|
|
3
|
+
const cmd = program
|
|
4
|
+
.command("nowledge-mem")
|
|
5
|
+
.description("Nowledge Mem knowledge base commands");
|
|
6
|
+
|
|
7
|
+
cmd
|
|
8
|
+
.command("search")
|
|
9
|
+
.argument("<query>", "Search query")
|
|
10
|
+
.option("--limit <n>", "Max results", "5")
|
|
11
|
+
.action(async (query, opts) => {
|
|
12
|
+
const limit = Number.parseInt(opts.limit, 10) || 5;
|
|
13
|
+
try {
|
|
14
|
+
const results = await client.search(query, limit);
|
|
15
|
+
if (results.length === 0) {
|
|
16
|
+
console.log("No memories found.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (const r of results) {
|
|
20
|
+
const pct = `${(r.score * 100).toFixed(0)}%`;
|
|
21
|
+
console.log(
|
|
22
|
+
`- [${pct}] ${r.title || "(untitled)"}: ${r.content.slice(0, 120)}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
logger.error(`cli search failed: ${err}`);
|
|
27
|
+
console.error("Search failed. Is nmem installed?");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
cmd
|
|
32
|
+
.command("status")
|
|
33
|
+
.description("Check Nowledge Mem status")
|
|
34
|
+
.action(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const healthy = await client.checkHealth();
|
|
37
|
+
console.log(
|
|
38
|
+
healthy ? "Nowledge Mem: running" : "Nowledge Mem: not responding",
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
console.log("Nowledge Mem: not installed");
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|