@ngo-a/native-memory-citations 2026.6.7 → 2026.6.9
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/README.md +126 -6
- package/dist/core.d.ts +69 -0
- package/dist/core.js +194 -1
- package/dist/enhanced.d.ts +48 -0
- package/dist/enhanced.js +205 -0
- package/dist/health.d.ts +1 -0
- package/dist/health.js +164 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +159 -36
- package/openclaw.plugin.json +157 -2
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -13,6 +13,25 @@ access; it is operator-controlled retrieval with an audit trail.
|
|
|
13
13
|
|
|
14
14
|

|
|
15
15
|
|
|
16
|
+
## Operating modes
|
|
17
|
+
|
|
18
|
+
The plugin runs in one of two modes, selected by the `mode` configuration key.
|
|
19
|
+
|
|
20
|
+
- **`bounded` (default).** The behavior described throughout this README: read-only
|
|
21
|
+
retrieval, keyword/substring search, extractive cited answers, no network calls, no
|
|
22
|
+
model calls, and no changes to host configuration. This is what a default install
|
|
23
|
+
does, and what every guarantee in this document refers to.
|
|
24
|
+
- **`enhanced` (opt-in).** Layers the three pillars of agentic memory - a local
|
|
25
|
+
knowledge graph, semantic and reranked recall, session-snapshot injection, and
|
|
26
|
+
observation tagging - on top of the bounded core, reusing the same access boundary,
|
|
27
|
+
redaction, and citation guarantees. Every enhanced capability is disabled by default
|
|
28
|
+
even in enhanced mode and is turned on explicitly, per feature.
|
|
29
|
+
|
|
30
|
+
Leaving configuration at its defaults keeps the plugin in bounded mode; an upgrade
|
|
31
|
+
changes nothing until you opt in. Enhanced-mode capabilities are introduced
|
|
32
|
+
incrementally beginning with the 2026.6.9 release - see
|
|
33
|
+
[Enhanced mode](#enhanced-mode-opt-in).
|
|
34
|
+
|
|
16
35
|
## Key capabilities
|
|
17
36
|
|
|
18
37
|
- Operator-defined search scope, limited to workspace-relative roots.
|
|
@@ -27,18 +46,22 @@ access; it is operator-controlled retrieval with an audit trail.
|
|
|
27
46
|
|
|
28
47
|
From npm (recommended):
|
|
29
48
|
|
|
30
|
-
|
|
49
|
+
```sh
|
|
50
|
+
openclaw plugins install @ngo-a/native-memory-citations
|
|
51
|
+
```
|
|
31
52
|
|
|
32
53
|
From a local checkout (development):
|
|
33
54
|
|
|
34
|
-
|
|
55
|
+
```sh
|
|
56
|
+
openclaw plugins install ./native-memory-citations
|
|
57
|
+
```
|
|
35
58
|
|
|
36
59
|
Reload the Gateway after installing so the plugin host exposes the tools.
|
|
37
60
|
|
|
38
61
|
## Requirements
|
|
39
62
|
|
|
40
63
|
- Node.js 22.19.0 or newer.
|
|
41
|
-
- OpenClaw 2026.
|
|
64
|
+
- OpenClaw 2026.6.8 or newer (declared as a peer dependency).
|
|
42
65
|
- A local OpenClaw workspace containing text memory files.
|
|
43
66
|
|
|
44
67
|
Supported memory file types are `.md`, `.txt`, `.json`, `.jsonl`, `.yaml`, and
|
|
@@ -53,9 +76,9 @@ identity files, and tool references must be handled within clear boundaries.
|
|
|
53
76
|
|
|
54
77
|
## Tools
|
|
55
78
|
|
|
56
|
-
- `native_memory_search`
|
|
57
|
-
- `native_memory_fetch`
|
|
58
|
-
- `native_memory_answer`
|
|
79
|
+
- `native_memory_search` - search the approved roots and return snippets with source paths, line numbers, and file SHA-256 hashes.
|
|
80
|
+
- `native_memory_fetch` - fetch a cited source by `sourceId` or a safe path, optionally checking an expected citation hash.
|
|
81
|
+
- `native_memory_answer` - build an extractive answer from cited snippets, and state plainly when no cited memory is found.
|
|
59
82
|
|
|
60
83
|
## Default scope
|
|
61
84
|
|
|
@@ -185,6 +208,103 @@ marks the result with `stale: true` and a `staleMessage` explaining the hash
|
|
|
185
208
|
mismatch. Because hashes cover the full file, appending to a daily journal marks
|
|
186
209
|
earlier citations stale even when the cited lines themselves are unchanged.
|
|
187
210
|
|
|
211
|
+
## Enhanced mode (opt-in)
|
|
212
|
+
|
|
213
|
+
Enhanced mode layers the three pillars of agentic memory - storage, injection, and
|
|
214
|
+
recall - on top of the bounded core, while reusing the same access boundary,
|
|
215
|
+
redaction, and citation guarantees. It is opt-in and default-off: setting
|
|
216
|
+
`mode: "enhanced"` turns on the framework, and each pillar is then enabled
|
|
217
|
+
individually. Doing nothing leaves the plugin in bounded mode.
|
|
218
|
+
|
|
219
|
+
> **Availability (2026.6.9).** Enhanced mode is delivered incrementally. This release
|
|
220
|
+
> ships the bounded-mode guardrails, the enhanced config schema, plugin health checks,
|
|
221
|
+
> the deterministic zero-LLM knowledge-graph sidecar, and the enhanced lifecycle
|
|
222
|
+
> scaffolding. The richer recall, model-based, and wiki pillars - and full runtime
|
|
223
|
+
> dispatch/soak validation of the lifecycle hooks - are still pending (see Forthcoming).
|
|
224
|
+
> On any version, an unset or default configuration behaves exactly as bounded mode.
|
|
225
|
+
|
|
226
|
+
### What 2026.6.9 ships
|
|
227
|
+
|
|
228
|
+
- **Bounded mode (default)** - unchanged guardrails and behavior (everything above).
|
|
229
|
+
- **Enhanced config schema + plugin health checks** - all enhanced keys are
|
|
230
|
+
schema-declared (so `openclaw doctor --fix` will not prune them), and the plugin
|
|
231
|
+
registers health checks visible to `openclaw doctor`.
|
|
232
|
+
- **Knowledge graph, zero-LLM** (`graph.enabled`). `native_memory_extract` writes typed
|
|
233
|
+
entity links (for example `works_at`, `invested_in`, `founded`) from your authorized
|
|
234
|
+
memory files into `memory/graph.jsonl` with no model call; `native_memory_graph`
|
|
235
|
+
queries it with a hard depth cap and cycle prevention. This pillar is functional in
|
|
236
|
+
this release.
|
|
237
|
+
- **Enhanced lifecycle scaffolding** (`injection.enabled`, `observations.enabled`,
|
|
238
|
+
`recall.snapshotFirst`). The code paths are present: `session_start` writes a capped
|
|
239
|
+
session snapshot, `before_prompt_build` injects it, and `agent_end` appends to
|
|
240
|
+
`memory/observations.jsonl` within `observations.maxBytes`. They require their host hook gates
|
|
241
|
+
(`hooks.allowPromptInjection` for injection, `hooks.allowConversationAccess` for the
|
|
242
|
+
observation append). On external-CLI runners or any other harness where in-process
|
|
243
|
+
lifecycle hooks do not dispatch, these hook-dependent features degrade cleanly: the
|
|
244
|
+
turn is unaffected and the three core cited-memory tools still work. **Runtime hook
|
|
245
|
+
dispatch and soak validation are still pending - treat these as experimental until
|
|
246
|
+
validated on the embedded runner.**
|
|
247
|
+
|
|
248
|
+
### Forthcoming (not in 2026.6.9)
|
|
249
|
+
|
|
250
|
+
- Semantic recall fusion through the host `memory_search`, RRF reranking, intent
|
|
251
|
+
classification, and snapshot-first recall inside `native_memory_search` /
|
|
252
|
+
`native_memory_answer`.
|
|
253
|
+
- Model-based observation extraction (the current path appends without a model) and its
|
|
254
|
+
fail-open-under-slow-model validation. When it ships, the default will use the
|
|
255
|
+
host's configured summarization/fast model, not a provider-specific model name.
|
|
256
|
+
- The `memory-wiki` bridge.
|
|
257
|
+
- External gateway-perf, package-gauntlet, and long-soak validation lanes.
|
|
258
|
+
|
|
259
|
+
### Dreaming requirement
|
|
260
|
+
|
|
261
|
+
Enhanced mode builds on OpenClaw's built-in dream cycle (Light -> REM -> Deep
|
|
262
|
+
consolidation), which is **off by default in OpenClaw**. When you enable enhanced
|
|
263
|
+
mode, the plugin enables dreaming for you and prints a notice explaining that it
|
|
264
|
+
*continues* - and does not replace - OpenClaw's dreaming, and that disabling dreaming
|
|
265
|
+
while enhanced mode is on will degrade or silently break these features. Bounded mode
|
|
266
|
+
never touches the dreaming setting. (In `2026.6.9` the guard's code path ships; its
|
|
267
|
+
host-config mutation on real startup is part of the dispatch validation still pending.)
|
|
268
|
+
|
|
269
|
+
### Configuration (enhanced keys)
|
|
270
|
+
|
|
271
|
+
All keys default off/false; an absent block means bounded mode. Every key is part of
|
|
272
|
+
the plugin schema, so `openclaw doctor --fix` will not prune it. Keys for forthcoming
|
|
273
|
+
pillars - `recall.semantic`, `recall.rerank`, `recall.intentClassifier`,
|
|
274
|
+
`observations.model` and model-based `observations.extraction`, and `wikiBridge.enabled`
|
|
275
|
+
- are accepted by the schema but have no effect until those pillars ship. Omit
|
|
276
|
+
`observations.model` to use the host's configured summarization/fast model when
|
|
277
|
+
model-based extraction is implemented; if no host model is available, observation
|
|
278
|
+
tagging falls back to the no-model append path.
|
|
279
|
+
|
|
280
|
+
| Key | Type | Default | Description |
|
|
281
|
+
|-----|------|---------|-------------|
|
|
282
|
+
| `mode` | string | `"bounded"` | `"bounded"` or `"enhanced"`. |
|
|
283
|
+
| `graph.enabled` | boolean | `false` | Enable knowledge-graph extraction and the graph tools. |
|
|
284
|
+
| `graph.edgeTypes` | string[] | built-in set | Typed edges to extract. |
|
|
285
|
+
| `graph.maxDepth` | number | `3` | Hard cap on multi-hop traversal depth. |
|
|
286
|
+
| `recall.semantic` | boolean | `false` | Add semantic retrieval to search/answer. |
|
|
287
|
+
| `recall.rerank` | boolean | `false` | Rerank retrieved results by relevance. |
|
|
288
|
+
| `recall.snapshotFirst` | boolean | `false` | Check the session snapshot before deeper search. |
|
|
289
|
+
| `recall.intentClassifier` | boolean | `false` | Classify query intent to steer retrieval. |
|
|
290
|
+
| `injection.enabled` | boolean | `false` | Inject a capped session snapshot into context. |
|
|
291
|
+
| `injection.tokenCap` | number | `1300` | Maximum tokens injected per session. |
|
|
292
|
+
| `observations.enabled` | boolean | `false` | Tag per-turn observations. |
|
|
293
|
+
| `observations.model` | string | host default | Optional model profile for future extraction; when omitted, use the host's configured summarization/fast model. |
|
|
294
|
+
| `observations.extraction` | boolean | `true` | When `false`, record raw entries with no model call. |
|
|
295
|
+
| `observations.maxBytes` | number | `1048576` | Maximum retained size for `memory/observations.jsonl` in enhanced mode. |
|
|
296
|
+
| `dreaming.autoEnable` | boolean | `true` | In enhanced mode, enable host dreaming if it is off. |
|
|
297
|
+
| `dreaming.enforce` | boolean | `true` | Re-warn at startup if dreaming is disabled in enhanced mode. |
|
|
298
|
+
| `dreaming.blockToolsWhenOff` | boolean | `false` | If `true`, dreaming-dependent tools error instead of degrading when dreaming is off. |
|
|
299
|
+
| `wikiBridge.enabled` | boolean | `false` | Enrich a separately installed `memory-wiki` vault, if present. |
|
|
300
|
+
|
|
301
|
+
### Compatibility
|
|
302
|
+
|
|
303
|
+
Enhanced mode requires a newer OpenClaw floor than bounded mode; the package declares
|
|
304
|
+
the required version per release. Hook-dependent features require a host harness that
|
|
305
|
+
dispatches in-process lifecycle hooks; external CLI runners and other non-dispatching
|
|
306
|
+
harnesses keep the core tools working and simply do not run those enhanced hooks.
|
|
307
|
+
|
|
188
308
|
## Security model
|
|
189
309
|
|
|
190
310
|
The plugin enforces a two-layer model.
|
package/dist/core.d.ts
CHANGED
|
@@ -3,6 +3,65 @@ export type PluginConfig = {
|
|
|
3
3
|
allowedRoots?: string[];
|
|
4
4
|
sharedMode?: boolean;
|
|
5
5
|
maxFileBytes?: number;
|
|
6
|
+
mode?: "bounded" | "enhanced";
|
|
7
|
+
dreaming?: {
|
|
8
|
+
autoEnable?: boolean;
|
|
9
|
+
enforce?: boolean;
|
|
10
|
+
blockToolsWhenOff?: boolean;
|
|
11
|
+
};
|
|
12
|
+
graph?: {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
edgeTypes?: GraphEdgeType[];
|
|
15
|
+
maxDepth?: number;
|
|
16
|
+
};
|
|
17
|
+
recall?: {
|
|
18
|
+
semantic?: boolean;
|
|
19
|
+
rerank?: boolean;
|
|
20
|
+
snapshotFirst?: boolean;
|
|
21
|
+
intentClassifier?: boolean;
|
|
22
|
+
};
|
|
23
|
+
injection?: {
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
tokenCap?: number;
|
|
26
|
+
};
|
|
27
|
+
observations?: {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
model?: string;
|
|
30
|
+
extraction?: boolean;
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
};
|
|
33
|
+
wikiBridge?: {
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export type GraphEdgeType = "works_at" | "invested_in" | "founded" | "advises" | "attended" | "mentions";
|
|
38
|
+
export type GraphEdge = {
|
|
39
|
+
from: string;
|
|
40
|
+
type: GraphEdgeType;
|
|
41
|
+
to: string;
|
|
42
|
+
sourceFile: string;
|
|
43
|
+
sourceLine: number;
|
|
44
|
+
extractedAt: string;
|
|
45
|
+
};
|
|
46
|
+
export type GraphExtractResult = {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
mode: "bounded" | "enhanced";
|
|
49
|
+
graphPath: string;
|
|
50
|
+
edgeCount: number;
|
|
51
|
+
skipped?: string;
|
|
52
|
+
};
|
|
53
|
+
export type GraphPath = {
|
|
54
|
+
nodes: string[];
|
|
55
|
+
edges: GraphEdge[];
|
|
56
|
+
};
|
|
57
|
+
export type GraphQueryResult = {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
mode: "bounded" | "enhanced";
|
|
60
|
+
query: string;
|
|
61
|
+
maxDepth: number;
|
|
62
|
+
edgeCount: number;
|
|
63
|
+
paths: GraphPath[];
|
|
64
|
+
skipped?: string;
|
|
6
65
|
};
|
|
7
66
|
export type SearchHit = {
|
|
8
67
|
sourceId: string;
|
|
@@ -38,6 +97,9 @@ export type MemoryLogger = {
|
|
|
38
97
|
error?: (message: string) => void;
|
|
39
98
|
};
|
|
40
99
|
export declare function workspaceFromConfig(config?: PluginConfig): string;
|
|
100
|
+
export declare function modeFromConfig(config?: PluginConfig): "bounded" | "enhanced";
|
|
101
|
+
export declare function isEnhancedMode(config?: PluginConfig): boolean;
|
|
102
|
+
export declare function graphEnabled(config?: PluginConfig): boolean;
|
|
41
103
|
export declare function allowedRoots(config?: PluginConfig): string[];
|
|
42
104
|
export declare function toSafePath(config: PluginConfig, requested: string): Promise<string>;
|
|
43
105
|
export declare function sourceIdForPath(config: PluginConfig, absolutePath: string): string;
|
|
@@ -49,6 +111,13 @@ export declare function searchMemory(query: string, options?: {
|
|
|
49
111
|
signal?: AbortSignal;
|
|
50
112
|
logger?: MemoryLogger;
|
|
51
113
|
}): Promise<SearchHit[]>;
|
|
114
|
+
export declare function extractMemoryGraph(config?: PluginConfig, options?: {
|
|
115
|
+
logger?: MemoryLogger;
|
|
116
|
+
}): Promise<GraphExtractResult>;
|
|
117
|
+
export declare function queryMemoryGraph(query: string, options?: {
|
|
118
|
+
maxDepth?: number;
|
|
119
|
+
config?: PluginConfig;
|
|
120
|
+
}): Promise<GraphQueryResult>;
|
|
52
121
|
export declare function fetchMemorySource(input: {
|
|
53
122
|
sourceId?: string;
|
|
54
123
|
filePath?: string;
|
package/dist/core.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -17,6 +17,8 @@ const DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
|
|
|
17
17
|
const SCAN_CONCURRENCY = 8;
|
|
18
18
|
const HIGH_ENTROPY_TOKEN_MIN_LENGTH = 24;
|
|
19
19
|
const HIGH_ENTROPY_TOKEN_MIN_BITS_PER_CHAR = 4;
|
|
20
|
+
const DEFAULT_GRAPH_EDGE_TYPES = ["works_at", "invested_in", "founded", "advises", "attended", "mentions"];
|
|
21
|
+
const GRAPH_EXTRACTED_AT = "1970-01-01T00:00:00.000Z";
|
|
20
22
|
const STOPWORDS = new Set([
|
|
21
23
|
"the",
|
|
22
24
|
"a",
|
|
@@ -93,6 +95,22 @@ function defaultWorkspace() {
|
|
|
93
95
|
export function workspaceFromConfig(config = {}) {
|
|
94
96
|
return path.resolve(config.workspace?.trim() || defaultWorkspace());
|
|
95
97
|
}
|
|
98
|
+
export function modeFromConfig(config = {}) {
|
|
99
|
+
return config.mode === "enhanced" ? "enhanced" : "bounded";
|
|
100
|
+
}
|
|
101
|
+
export function isEnhancedMode(config = {}) {
|
|
102
|
+
return modeFromConfig(config) === "enhanced";
|
|
103
|
+
}
|
|
104
|
+
export function graphEnabled(config = {}) {
|
|
105
|
+
return isEnhancedMode(config) && config.graph?.enabled === true;
|
|
106
|
+
}
|
|
107
|
+
function graphPath(config = {}) {
|
|
108
|
+
return path.join(workspaceFromConfig(config), "memory", "graph.jsonl");
|
|
109
|
+
}
|
|
110
|
+
function enabledGraphEdgeTypes(config = {}) {
|
|
111
|
+
const configured = config.graph?.edgeTypes?.length ? config.graph.edgeTypes : DEFAULT_GRAPH_EDGE_TYPES;
|
|
112
|
+
return new Set(configured.filter((type) => DEFAULT_GRAPH_EDGE_TYPES.includes(type)));
|
|
113
|
+
}
|
|
96
114
|
export function allowedRoots(config = {}) {
|
|
97
115
|
const roots = config.allowedRoots && config.allowedRoots.length > 0
|
|
98
116
|
? config.allowedRoots
|
|
@@ -231,6 +249,14 @@ function publicHit(hit) {
|
|
|
231
249
|
sha256: hit.sha256,
|
|
232
250
|
};
|
|
233
251
|
}
|
|
252
|
+
function graphSourceRoots(config) {
|
|
253
|
+
const workspace = workspaceFromConfig(config);
|
|
254
|
+
return [path.join(workspace, "memory"), path.join(workspace, "MEMORY.md")];
|
|
255
|
+
}
|
|
256
|
+
function isDerivedMemoryArtifact(config, file) {
|
|
257
|
+
const sourceId = sourceIdForPath(config, file);
|
|
258
|
+
return sourceId === "memory/graph.jsonl" || sourceId === "memory/observations.jsonl";
|
|
259
|
+
}
|
|
234
260
|
async function collectFiles(root, logger) {
|
|
235
261
|
const info = await stat(root).catch(() => null);
|
|
236
262
|
if (!info) {
|
|
@@ -432,6 +458,173 @@ async function searchMemoryInternal(query, options = {}) {
|
|
|
432
458
|
export async function searchMemory(query, options = {}) {
|
|
433
459
|
return (await searchMemoryInternal(query, options)).map(publicHit);
|
|
434
460
|
}
|
|
461
|
+
function normalizeEntity(value) {
|
|
462
|
+
return value
|
|
463
|
+
.replace(/[`*_()[\]{}]/g, "")
|
|
464
|
+
.replace(/\s+/g, " ")
|
|
465
|
+
.replace(/[.,;:!?]+$/g, "")
|
|
466
|
+
.trim();
|
|
467
|
+
}
|
|
468
|
+
function addEdge(edges, seen, edge) {
|
|
469
|
+
const normalized = {
|
|
470
|
+
...edge,
|
|
471
|
+
from: normalizeEntity(edge.from),
|
|
472
|
+
to: normalizeEntity(edge.to),
|
|
473
|
+
extractedAt: GRAPH_EXTRACTED_AT,
|
|
474
|
+
};
|
|
475
|
+
if (!normalized.from || !normalized.to || normalized.from === normalized.to) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const key = `${normalized.from}\0${normalized.type}\0${normalized.to}\0${normalized.sourceFile}\0${normalized.sourceLine}`;
|
|
479
|
+
if (seen.has(key)) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
seen.add(key);
|
|
483
|
+
edges.push(normalized);
|
|
484
|
+
}
|
|
485
|
+
function extractEdgesFromLine(line, sourceFile, sourceLine, allowedTypes) {
|
|
486
|
+
const edges = [];
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
const cleaned = line.replace(/^\s*(?:[-*]|\d+[.)])\s+/, "").trim();
|
|
489
|
+
const properNoun = "([A-Z][\\w@.+-]*(?:\\s+[A-Z][\\w@.+-]*){0,5})";
|
|
490
|
+
const target = "([^.;\\n]{2,120})";
|
|
491
|
+
const patterns = [
|
|
492
|
+
{ type: "works_at", re: new RegExp(`${properNoun}\\s+(?:works|worked)\\s+(?:at|for)\\s+${target}`, "i") },
|
|
493
|
+
{ type: "invested_in", re: new RegExp(`${properNoun}\\s+(?:invested in|backs|backed)\\s+${target}`, "i") },
|
|
494
|
+
{ type: "founded", re: new RegExp(`${properNoun}\\s+(?:founded|co-founded|started)\\s+${target}`, "i") },
|
|
495
|
+
{ type: "advises", re: new RegExp(`${properNoun}\\s+(?:advises|advised|mentors|mentor(?:ed)?)\\s+${target}`, "i") },
|
|
496
|
+
{ type: "attended", re: new RegExp(`${properNoun}\\s+(?:attended|went to)\\s+${target}`, "i") },
|
|
497
|
+
{ type: "mentions", re: new RegExp(`${properNoun}\\s+(?:mentions|mentioned|discusses|discussed)\\s+${target}`, "i") },
|
|
498
|
+
];
|
|
499
|
+
for (const pattern of patterns) {
|
|
500
|
+
if (!allowedTypes.has(pattern.type)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const match = cleaned.match(pattern.re);
|
|
504
|
+
if (match?.[1] && match[2]) {
|
|
505
|
+
addEdge(edges, seen, { from: match[1], type: pattern.type, to: match[2], sourceFile, sourceLine });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return edges;
|
|
509
|
+
}
|
|
510
|
+
async function readGraphEdges(config) {
|
|
511
|
+
const text = await readFile(graphPath(config), "utf8").catch(() => "");
|
|
512
|
+
if (!text.trim()) {
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
const edges = [];
|
|
516
|
+
for (const line of text.split(/\r?\n/g)) {
|
|
517
|
+
if (!line.trim()) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const parsed = JSON.parse(line);
|
|
521
|
+
if (typeof parsed.from === "string"
|
|
522
|
+
&& DEFAULT_GRAPH_EDGE_TYPES.includes(parsed.type)
|
|
523
|
+
&& typeof parsed.to === "string"
|
|
524
|
+
&& typeof parsed.sourceFile === "string"
|
|
525
|
+
&& typeof parsed.sourceLine === "number"
|
|
526
|
+
&& typeof parsed.extractedAt === "string") {
|
|
527
|
+
edges.push(parsed);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return edges;
|
|
531
|
+
}
|
|
532
|
+
export async function extractMemoryGraph(config = {}, options = {}) {
|
|
533
|
+
const mode = modeFromConfig(config);
|
|
534
|
+
const target = sourceIdForPath(config, graphPath(config));
|
|
535
|
+
if (!graphEnabled(config)) {
|
|
536
|
+
return {
|
|
537
|
+
enabled: false,
|
|
538
|
+
mode,
|
|
539
|
+
graphPath: target,
|
|
540
|
+
edgeCount: 0,
|
|
541
|
+
skipped: mode === "enhanced" ? "graph.enabled is false" : "mode is bounded",
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const allowedTypes = enabledGraphEdgeTypes(config);
|
|
545
|
+
const fileSizeLimit = maxFileBytes(config);
|
|
546
|
+
const files = (await Promise.all(graphSourceRoots(config).map((root) => collectFiles(root, options.logger)))).flat();
|
|
547
|
+
const edges = [];
|
|
548
|
+
const seen = new Set();
|
|
549
|
+
for (const file of files.sort()) {
|
|
550
|
+
if (isDerivedMemoryArtifact(config, file)) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const loaded = await loadFile(file, fileSizeLimit, options.logger);
|
|
554
|
+
if (!loaded) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const sourceFile = sourceIdForPath(config, file);
|
|
558
|
+
loaded.rawLines.forEach((line, index) => {
|
|
559
|
+
for (const edge of extractEdgesFromLine(line, sourceFile, index + 1, allowedTypes)) {
|
|
560
|
+
addEdge(edges, seen, edge);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
edges.sort((a, b) => a.from.localeCompare(b.from)
|
|
565
|
+
|| a.type.localeCompare(b.type)
|
|
566
|
+
|| a.to.localeCompare(b.to)
|
|
567
|
+
|| a.sourceFile.localeCompare(b.sourceFile)
|
|
568
|
+
|| a.sourceLine - b.sourceLine);
|
|
569
|
+
const file = graphPath(config);
|
|
570
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
571
|
+
await writeFile(file, `${edges.map((edge) => JSON.stringify(edge)).join("\n")}${edges.length ? "\n" : ""}`, "utf8");
|
|
572
|
+
return { enabled: true, mode, graphPath: target, edgeCount: edges.length };
|
|
573
|
+
}
|
|
574
|
+
export async function queryMemoryGraph(query, options = {}) {
|
|
575
|
+
const config = options.config ?? {};
|
|
576
|
+
const mode = modeFromConfig(config);
|
|
577
|
+
const maxDepth = clampInt(options.maxDepth ?? config.graph?.maxDepth, 3, 1, 6);
|
|
578
|
+
if (!graphEnabled(config)) {
|
|
579
|
+
return {
|
|
580
|
+
enabled: false,
|
|
581
|
+
mode,
|
|
582
|
+
query,
|
|
583
|
+
maxDepth,
|
|
584
|
+
edgeCount: 0,
|
|
585
|
+
paths: [],
|
|
586
|
+
skipped: mode === "enhanced" ? "graph.enabled is false" : "mode is bounded",
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const edges = await readGraphEdges(config);
|
|
590
|
+
const q = normalizeEntity(query).toLowerCase();
|
|
591
|
+
const starts = new Set();
|
|
592
|
+
for (const edge of edges) {
|
|
593
|
+
if (edge.from.toLowerCase().includes(q) || edge.to.toLowerCase().includes(q)) {
|
|
594
|
+
starts.add(edge.from);
|
|
595
|
+
starts.add(edge.to);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const adjacency = new Map();
|
|
599
|
+
for (const edge of edges) {
|
|
600
|
+
adjacency.set(edge.from, [...(adjacency.get(edge.from) ?? []), edge]);
|
|
601
|
+
adjacency.set(edge.to, [...(adjacency.get(edge.to) ?? []), { ...edge, from: edge.to, to: edge.from }]);
|
|
602
|
+
}
|
|
603
|
+
const paths = [];
|
|
604
|
+
const queue = Array.from(starts).sort().map((node) => ({ nodes: [node], edges: [] }));
|
|
605
|
+
const seenPaths = new Set();
|
|
606
|
+
while (queue.length > 0 && paths.length < 50) {
|
|
607
|
+
const current = queue.shift();
|
|
608
|
+
if (current.edges.length > 0) {
|
|
609
|
+
const key = current.nodes.join("\0");
|
|
610
|
+
if (!seenPaths.has(key)) {
|
|
611
|
+
seenPaths.add(key);
|
|
612
|
+
paths.push(current);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (current.edges.length >= maxDepth) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const last = current.nodes[current.nodes.length - 1] ?? "";
|
|
619
|
+
for (const edge of adjacency.get(last) ?? []) {
|
|
620
|
+
if (current.nodes.includes(edge.to)) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
queue.push({ nodes: [...current.nodes, edge.to], edges: [...current.edges, edge] });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return { enabled: true, mode, query, maxDepth, edgeCount: edges.length, paths };
|
|
627
|
+
}
|
|
435
628
|
export async function fetchMemorySource(input, config = {}) {
|
|
436
629
|
const requested = input.sourceId || input.filePath;
|
|
437
630
|
if (!requested) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type PluginConfig } from "./core.js";
|
|
2
|
+
type PluginApiLike = {
|
|
3
|
+
pluginConfig?: PluginConfig;
|
|
4
|
+
config?: OpenClawConfigLike;
|
|
5
|
+
logger?: {
|
|
6
|
+
debug?: (message: string) => void;
|
|
7
|
+
info?: (message: string) => void;
|
|
8
|
+
warn?: (message: string) => void;
|
|
9
|
+
error?: (message: string) => void;
|
|
10
|
+
};
|
|
11
|
+
registerHook?: (events: string | string[], handler: (event: unknown, ctx: unknown) => unknown, opts?: unknown) => void;
|
|
12
|
+
on?: (event: string, handler: (event: unknown, ctx: unknown) => unknown, opts?: unknown) => void;
|
|
13
|
+
runtime?: {
|
|
14
|
+
config?: {
|
|
15
|
+
current?: () => unknown;
|
|
16
|
+
mutateConfigFile?: (params: {
|
|
17
|
+
afterWrite: {
|
|
18
|
+
mode: "auto" | "none" | "restart" | "reload";
|
|
19
|
+
};
|
|
20
|
+
mutate: (draft: Record<string, unknown>) => void;
|
|
21
|
+
}) => Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
type OpenClawConfigLike = {
|
|
26
|
+
memory?: {
|
|
27
|
+
dreaming?: {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
plugins?: {
|
|
32
|
+
entries?: Record<string, {
|
|
33
|
+
config?: {
|
|
34
|
+
dreaming?: {
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
} | unknown>;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
declare function setDreamingEnabledOnConfig(draft: Record<string, unknown>): void;
|
|
42
|
+
export declare function registerEnhancedLifecycle(api: PluginApiLike): void;
|
|
43
|
+
export declare const enhancedLifecycleForTest: {
|
|
44
|
+
DREAMING_NOTICE: string;
|
|
45
|
+
PLUGIN_ID: string;
|
|
46
|
+
setDreamingEnabledOnConfig: typeof setDreamingEnabledOnConfig;
|
|
47
|
+
};
|
|
48
|
+
export {};
|