openclaw-amem 2.1.1 → 2.1.3
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 +14 -15
- package/dist/index.js +552 -131
- package/openclaw.plugin.json +11 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
**Memory for [OpenClaw](https://github.com/openclaw/openclaw) agents that catches its own contradictions.**
|
|
12
12
|
|
|
13
|
-
A nightly pass re-reads what changed and flags memories that no longer agree with each other
|
|
13
|
+
A nightly pass re-reads what changed and flags memories that no longer agree with each other. This is the failure mode every long-lived memory store eventually has. The plugin also extracts facts instead of storing transcripts. It rewrites notes as new ones arrive, links them into a Zettelkasten-style graph, and retrieves them with hybrid BM25 + dense search over 2-hop expansion. The plugin scopes memories per agent (`owner`/`readers`/`writers`) and per subject, and enforces both inside the Qdrant query rather than filtering after it.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The plugin runs on a small model — `gpt-4o-mini`, `haiku`, a local Ollama. It does not require Python.
|
|
16
16
|
|
|
17
17
|
中文走 [jieba](https://github.com/messense/node-jieba) 分词,提示词有完整中文版(`AMEM_PROMPT_LOCALE=zh`),embedding 模型本身多语言。
|
|
18
18
|
|
|
@@ -26,9 +26,9 @@ Requires a local [Qdrant](https://qdrant.tech). Implements [A-MEM](https://arxiv
|
|
|
26
26
|
|
|
27
27
|
- 🔄 **Memories evolve** — new facts update/link related memories (EVOLVE / CONFLICT / EXPAND / NEW), not silent overwrite.
|
|
28
28
|
- 🔍 **Hybrid retrieval** — BM25 (Jieba for Chinese) + dense vectors (RRF) + 2-hop graph expansion with a relevance gate.
|
|
29
|
-
- 🧠 **Knowledge vs episodic** — durable knowledge notes skip consolidation & time-decay
|
|
29
|
+
- 🧠 **Knowledge vs episodic** — durable knowledge notes skip consolidation & time-decay. Topic tags for precise recall.
|
|
30
30
|
- 🧹 **Self-consolidating** — daily 02:30 in-process merge of semantic duplicates with link cascading.
|
|
31
|
-
- 🔐 **Per-agent isolation** — private by default
|
|
31
|
+
- 🔐 **Per-agent isolation** — private by default. Explicit `owner`/`readers`/`writers`. Mode A (shared collection) or Mode B (dedicated collection).
|
|
32
32
|
- 🀄 **Chinese-optimized** & local embeddings (Transformers.js) — no Python, no external embedding API.
|
|
33
33
|
|
|
34
34
|
→ Full feature list & internals: **[@amemhq/core README](../amem-core)** · **[docs](https://amem.owo.lc)**.
|
|
@@ -36,7 +36,7 @@ Requires a local [Qdrant](https://qdrant.tech). Implements [A-MEM](https://arxiv
|
|
|
36
36
|
## Requirements
|
|
37
37
|
|
|
38
38
|
- OpenClaw v2026.4+
|
|
39
|
-
- Node.js 24 (18+ works
|
|
39
|
+
- Node.js 24 (18+ works, 24/26 supported)
|
|
40
40
|
- Qdrant running on `:6333`
|
|
41
41
|
- An LLM: `ANTHROPIC_API_KEY` by default, or any OpenAI-compatible provider — see [LLM provider](#llm-provider)
|
|
42
42
|
- ~1.1 GB of disk for the embedding model, downloaded once on first run
|
|
@@ -61,12 +61,11 @@ Updating:
|
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
63
|
openclaw plugins update openclaw-amem
|
|
64
|
-
openclaw gateway restart
|
|
65
64
|
```
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
plugin directory. Set that once and updates cost nothing.
|
|
66
|
+
The gateway does not load the new build until it restarts. Run `openclaw gateway restart` after the update.
|
|
67
|
+
|
|
68
|
+
An update rebuilds `node_modules`. The model cache lives there by default, so the plugin re-downloads 2.27 GB unless `AMEM_MODEL_CACHE` points somewhere outside the plugin directory. Set that once and updates cost nothing.
|
|
70
69
|
|
|
71
70
|
### 2. Configure `~/.openclaw/openclaw.json`
|
|
72
71
|
|
|
@@ -92,7 +91,7 @@ Add `openclaw-amem` to your allowed plugins and hook it into the `memory` slot:
|
|
|
92
91
|
}
|
|
93
92
|
```
|
|
94
93
|
|
|
95
|
-
> **⚠️ Memory slot conflict:** If your `openclaw.json` already assigns the `memory` slot to another plugin (
|
|
94
|
+
> **⚠️ Memory slot conflict:** If your `openclaw.json` already assigns the `memory` slot to another plugin (for example, `memory-core`), you **must** change it to `openclaw-amem`. On OpenClaw 2026.8.1 the slot alone is not enough. The other plugin still loads and takes the `memory_search` tool name, because the gateway loads built-in plugins before installed ones. Memory keeps working through the slot, but the tool an agent calls is then the other plugin's. Set `"memory-core": { "enabled": false }` in `entries` as well. Note that `memory-core` also writes its dream diary, and disabling it stops that too.
|
|
96
95
|
|
|
97
96
|
### 3. Restart OpenClaw
|
|
98
97
|
|
|
@@ -103,11 +102,11 @@ openclaw gateway restart
|
|
|
103
102
|
First run downloads the embedding model (`bge-m3`, 2.27 GB) and caches it. Later
|
|
104
103
|
restarts are instant. If that is too much, the only smaller model worth setting is
|
|
105
104
|
`AMEM_EMBED_MODEL=Xenova/bge-small-zh-v1.5` — 25 MB, Chinese only, and it caps at
|
|
106
|
-
512 tokens. Set it **before** you have memories,
|
|
105
|
+
512 tokens. Set it **before** you have memories, because changing it afterwards means
|
|
107
106
|
a [migration](https://amem.owo.lc/reference/embedding-models#changing-the-model-on-a-store-you-already-have).
|
|
108
107
|
|
|
109
108
|
Upgrading from 1.x downloads nothing. Your existing memories keep the model that
|
|
110
|
-
built them, and the plugin
|
|
109
|
+
built them, and the plugin shows this at startup along with the one command that
|
|
111
110
|
moves them.
|
|
112
111
|
|
|
113
112
|
## LLM provider
|
|
@@ -115,7 +114,7 @@ moves them.
|
|
|
115
114
|
The plugin calls an LLM for note construction, linking, and evolution. Pick the backend with `AMEM_LLM_PROVIDER`:
|
|
116
115
|
|
|
117
116
|
- **`anthropic`** (default) — the Anthropic Messages API. Set `ANTHROPIC_API_KEY`.
|
|
118
|
-
- **`openai`** — the OpenAI Chat Completions API, which every OpenAI-compatible endpoint speaks. Set `AMEM_LLM_PROVIDER=openai`, point `AMEM_LLM_BASE_URL` at the endpoint, and set `AMEM_LLM_API_KEY` (or the standard `OPENAI_API_KEY`). Covers **OpenAI, DeepSeek, OpenRouter, Groq, Together**, and local servers (**Ollama, vLLM, LM Studio** — no key needed).
|
|
117
|
+
- **`openai`** — the OpenAI Chat Completions API, which every OpenAI-compatible endpoint speaks. Set `AMEM_LLM_PROVIDER=openai`, point `AMEM_LLM_BASE_URL` at the endpoint, and set `AMEM_LLM_API_KEY` (or the standard `OPENAI_API_KEY`). Covers **OpenAI, DeepSeek, OpenRouter, Groq, Together**, and local servers (**Ollama, vLLM, LM Studio** — no key needed). The plugin handles reasoning models (`o1`, `o3`, `gpt-5`) automatically.
|
|
119
118
|
|
|
120
119
|
Choose the model with `AMEM_LLM_MODEL`. Full env-var reference and examples: **[amem.owo.lc/reference/configuration](https://amem.owo.lc/reference/configuration)**.
|
|
121
120
|
|
|
@@ -142,13 +141,13 @@ Once installed, the plugin exposes five tools to the agent:
|
|
|
142
141
|
|
|
143
142
|
## Security & data flow
|
|
144
143
|
|
|
145
|
-
A memory plugin's job is to read configuration from the environment and send memory data to backends **you** control, so registry static scanners flag its `env` + `network` pattern (
|
|
144
|
+
A memory plugin's job is to read configuration from the environment and send memory data to backends **you** control, so registry static scanners flag its `env` + `network` pattern (for example, ClawHub's `suspicious.env_credential_access`). This is structurally expected for any configurable memory/LLM plugin. The audit outcome is **advisory (`Review`), not `Malicious`**, and VirusTotal reports the bundle clean.
|
|
146
145
|
|
|
147
146
|
What it actually does — all of it declared in [`openclaw.plugin.json`](openclaw.plugin.json):
|
|
148
147
|
|
|
149
148
|
- **Environment variables it reads** (its configuration surface, supplied by you): `AMEM_LLM_PROVIDER`, `AMEM_LLM_API_KEY`, `OPENAI_API_KEY`, `AMEM_LLM_BASE_URL`, `AMEM_LLM_MODEL`, `AMEM_COLLECTION`, `AMEM_DATA_DIR`, `AMEM_EVO_COUNTER_PATH`, `AMEM_REVIEW_DIR`, `AMEM_PROMPT_LOCALE`. No credential is bundled, hardcoded, or logged.
|
|
150
149
|
- **Network destinations**: only your **local Qdrant** (`http://localhost:6333`) and your configured **LLM endpoint** (Anthropic by default, or any OpenAI-compatible endpoint via `AMEM_LLM_PROVIDER=openai` + `AMEM_LLM_BASE_URL`). It sends memory text/embeddings there to store and evolve notes — its stated purpose. It does not phone home.
|
|
151
|
-
- **
|
|
150
|
+
- The plugin processes **conversation content** for memory only when you set `hooks.allowConversationAccess: true`. Keep Qdrant and review-output paths scoped to locations you control.
|
|
152
151
|
|
|
153
152
|
## Development
|
|
154
153
|
|
package/dist/index.js
CHANGED
|
@@ -38,17 +38,22 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
38
38
|
// ../amem-core/src/config.ts
|
|
39
39
|
function configure(opts) {
|
|
40
40
|
if (opts.dataDir) _dataDir = opts.dataDir;
|
|
41
|
+
if (opts.warn) _warn = opts.warn;
|
|
41
42
|
}
|
|
42
43
|
function getDataDir() {
|
|
43
44
|
return _dataDir;
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
function warn(msg) {
|
|
47
|
+
_warn(msg);
|
|
48
|
+
}
|
|
49
|
+
var os, path, _dataDir, _warn;
|
|
46
50
|
var init_config = __esm({
|
|
47
51
|
"../amem-core/src/config.ts"() {
|
|
48
52
|
"use strict";
|
|
49
53
|
os = __toESM(require("os"), 1);
|
|
50
54
|
path = __toESM(require("path"), 1);
|
|
51
55
|
_dataDir = process.env.AMEM_DATA_DIR || path.join(os.homedir(), ".amem");
|
|
56
|
+
_warn = (msg) => console.warn(msg);
|
|
52
57
|
}
|
|
53
58
|
});
|
|
54
59
|
|
|
@@ -87,6 +92,12 @@ function applyModelPaths(env) {
|
|
|
87
92
|
env.allowLocalModels = true;
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
function humanBytes(n) {
|
|
96
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
|
|
97
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`;
|
|
98
|
+
if (n >= 1e3) return `${Math.round(n / 1e3)} kB`;
|
|
99
|
+
return `${n} B`;
|
|
100
|
+
}
|
|
90
101
|
function makeProgressReporter() {
|
|
91
102
|
const lastPct = /* @__PURE__ */ new Map();
|
|
92
103
|
return (e) => {
|
|
@@ -95,7 +106,7 @@ function makeProgressReporter() {
|
|
|
95
106
|
const pct = Math.floor(e.progress / 10) * 10;
|
|
96
107
|
if (lastPct.get(e.file) === pct) return;
|
|
97
108
|
lastPct.set(e.file, pct);
|
|
98
|
-
const size = e.total ? ` of ${(e.total
|
|
109
|
+
const size = e.total ? ` of ${humanBytes(e.total)}` : "";
|
|
99
110
|
console.log(`[amem] downloading ${e.file}: ${pct}%${size}`);
|
|
100
111
|
};
|
|
101
112
|
}
|
|
@@ -246,15 +257,15 @@ async function recordCollectionModel(collection, model) {
|
|
|
246
257
|
} catch {
|
|
247
258
|
}
|
|
248
259
|
}
|
|
249
|
-
async function qdrant(method,
|
|
250
|
-
const res = await fetch(`${QDRANT_URL}${
|
|
260
|
+
async function qdrant(method, path7, body) {
|
|
261
|
+
const res = await fetch(`${QDRANT_URL}${path7}`, {
|
|
251
262
|
method,
|
|
252
263
|
headers: { "Content-Type": "application/json" },
|
|
253
264
|
body: body ? JSON.stringify(body) : void 0
|
|
254
265
|
});
|
|
255
266
|
const data = await res.json();
|
|
256
267
|
if (!res.ok || data.status && data.status !== "ok" && data.status !== "acknowledged") {
|
|
257
|
-
throw new Error(`Qdrant ${method} ${
|
|
268
|
+
throw new Error(`Qdrant ${method} ${path7} failed: ${data.error || JSON.stringify(data)}`);
|
|
258
269
|
}
|
|
259
270
|
return data.result;
|
|
260
271
|
}
|
|
@@ -294,7 +305,7 @@ async function ensureCollection(collectionName) {
|
|
|
294
305
|
if (inUse !== null && inUse !== wanted) throw new MixedEmbeddingModelsError(col, wanted, inUse);
|
|
295
306
|
pinEmbeddingModel(wanted);
|
|
296
307
|
if (wanted === LEGACY_DEFAULT_EMBEDDING_MODEL) {
|
|
297
|
-
|
|
308
|
+
warn(
|
|
298
309
|
`[amem] "${col}" is on ${LEGACY_DEFAULT_EMBEDDING_MODEL} (${LEGACY_DEFAULT_DIM}-dim).
|
|
299
310
|
[amem] ${DEFAULT_EMBEDDING_MODEL} reads 8192 tokens where that one stops at 128, so anything longer is being truncated before it reaches the vector.
|
|
300
311
|
[amem] To move: npx --package=@amemhq/core amem-migrate --from-collection ${col}`
|
|
@@ -307,12 +318,12 @@ async function ensureCollection(collectionName) {
|
|
|
307
318
|
throw new EmbeddingDimensionMismatchError(col, collectionDim, modelDim, getEmbeddingModel());
|
|
308
319
|
}
|
|
309
320
|
}
|
|
310
|
-
const
|
|
311
|
-
if (typeof recorded === "string" && recorded !==
|
|
312
|
-
throw new EmbeddingModelMismatchError(col, recorded,
|
|
321
|
+
const current2 = getEmbeddingModel();
|
|
322
|
+
if (typeof recorded === "string" && recorded !== current2) {
|
|
323
|
+
throw new EmbeddingModelMismatchError(col, recorded, current2);
|
|
313
324
|
}
|
|
314
325
|
if (recorded === void 0 && !inferLegacy) {
|
|
315
|
-
await recordCollectionModel(col,
|
|
326
|
+
await recordCollectionModel(col, current2);
|
|
316
327
|
}
|
|
317
328
|
markReady();
|
|
318
329
|
return;
|
|
@@ -698,7 +709,7 @@ function makeCrud(collectionName, modeBIsolated = false) {
|
|
|
698
709
|
})
|
|
699
710
|
)
|
|
700
711
|
]).catch((err) => {
|
|
701
|
-
|
|
712
|
+
warn(`[amem] retrieval tracking patch failed: ${err.message}`);
|
|
702
713
|
});
|
|
703
714
|
for (const r of queryResults) {
|
|
704
715
|
r.note.retrieval_count = (r.note.retrieval_count || 0) + 1;
|
|
@@ -818,6 +829,7 @@ var init_storage = __esm({
|
|
|
818
829
|
"../amem-core/src/storage.ts"() {
|
|
819
830
|
"use strict";
|
|
820
831
|
init_auth();
|
|
832
|
+
init_config();
|
|
821
833
|
init_embedding();
|
|
822
834
|
QDRANT_URL = "http://localhost:6333";
|
|
823
835
|
getCollection = () => process.env.AMEM_COLLECTION || "amem_notes";
|
|
@@ -1119,6 +1131,45 @@ ${numberedNotes}
|
|
|
1119
1131
|
}
|
|
1120
1132
|
});
|
|
1121
1133
|
|
|
1134
|
+
// ../amem-core/src/deadline.ts
|
|
1135
|
+
function hasTimeFor(deadline) {
|
|
1136
|
+
return deadline === void 0 || deadline - Date.now() >= MIN_CALL_MS;
|
|
1137
|
+
}
|
|
1138
|
+
var MIN_CALL_MS;
|
|
1139
|
+
var init_deadline = __esm({
|
|
1140
|
+
"../amem-core/src/deadline.ts"() {
|
|
1141
|
+
"use strict";
|
|
1142
|
+
MIN_CALL_MS = 3e3;
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// ../amem-core/src/background.ts
|
|
1147
|
+
function runInBackground(busy, job) {
|
|
1148
|
+
return current.run(busy, job);
|
|
1149
|
+
}
|
|
1150
|
+
function stopIfForegroundBusy() {
|
|
1151
|
+
if (current.getStore()?.()) throw new BackgroundPreempted();
|
|
1152
|
+
}
|
|
1153
|
+
async function pauseForForeground() {
|
|
1154
|
+
if (!current.getStore()) return;
|
|
1155
|
+
await new Promise((resolve2) => setImmediate(resolve2));
|
|
1156
|
+
stopIfForegroundBusy();
|
|
1157
|
+
}
|
|
1158
|
+
var import_node_async_hooks, current, BackgroundPreempted;
|
|
1159
|
+
var init_background = __esm({
|
|
1160
|
+
"../amem-core/src/background.ts"() {
|
|
1161
|
+
"use strict";
|
|
1162
|
+
import_node_async_hooks = require("async_hooks");
|
|
1163
|
+
current = new import_node_async_hooks.AsyncLocalStorage();
|
|
1164
|
+
BackgroundPreempted = class extends Error {
|
|
1165
|
+
constructor() {
|
|
1166
|
+
super("stopped for a run in the foreground");
|
|
1167
|
+
this.name = "BackgroundPreempted";
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1122
1173
|
// ../amem-core/src/llm.ts
|
|
1123
1174
|
function configureLlm(cfg) {
|
|
1124
1175
|
_override = { ...cfg };
|
|
@@ -1128,7 +1179,7 @@ function configureLlm(cfg) {
|
|
|
1128
1179
|
function warnOnce(key, message) {
|
|
1129
1180
|
if (_warned.has(key)) return;
|
|
1130
1181
|
_warned.add(key);
|
|
1131
|
-
|
|
1182
|
+
warn(message);
|
|
1132
1183
|
}
|
|
1133
1184
|
function resolveProvider(role = "fast") {
|
|
1134
1185
|
const raw = role === "strong" ? process.env.AMEM_LLM_STRONG_PROVIDER || _override.strong?.provider || void 0 : void 0;
|
|
@@ -1154,6 +1205,12 @@ function resolveCrudRole() {
|
|
|
1154
1205
|
}
|
|
1155
1206
|
return "fast";
|
|
1156
1207
|
}
|
|
1208
|
+
function resolveThinking() {
|
|
1209
|
+
const raw = (process.env.AMEM_LLM_THINKING || _override.thinking || "off").trim().toLowerCase();
|
|
1210
|
+
if (raw === "auto") return "auto";
|
|
1211
|
+
if (raw !== "off") warnOnce(`thinking:${raw}`, `[amem] unknown AMEM_LLM_THINKING "${raw}"; using off`);
|
|
1212
|
+
return "off";
|
|
1213
|
+
}
|
|
1157
1214
|
function resolveTimeoutMs() {
|
|
1158
1215
|
const envVal = Number(process.env.AMEM_LLM_TIMEOUT);
|
|
1159
1216
|
if (Number.isFinite(envVal) && envVal > 0) return envVal;
|
|
@@ -1190,37 +1247,70 @@ function openai(baseURL) {
|
|
|
1190
1247
|
}
|
|
1191
1248
|
return client;
|
|
1192
1249
|
}
|
|
1193
|
-
async function llmCall(prompt, maxTokens = 500, role = "fast") {
|
|
1250
|
+
async function llmCall(prompt, maxTokens = 500, role = "fast", opts = {}) {
|
|
1251
|
+
stopIfForegroundBusy();
|
|
1252
|
+
if (!hasTimeFor(opts.deadline)) {
|
|
1253
|
+
warn(`[amem] skipped an LLM call: less than ${MIN_CALL_MS} ms left before the deadline`);
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
const request = () => opts.deadline === void 0 ? void 0 : { timeout: Math.min(resolveTimeoutMs(), opts.deadline - Date.now()), maxRetries: 0 };
|
|
1194
1257
|
const provider = resolveProvider(role);
|
|
1195
1258
|
const model = resolveModel(role);
|
|
1196
1259
|
const baseURL = resolveBaseURL(role);
|
|
1197
|
-
const
|
|
1198
|
-
const
|
|
1260
|
+
const pair = `${baseURL ?? ""}|${model}`;
|
|
1261
|
+
const thinkingOff = provider !== "openai" && role === "fast" && resolveThinking() === "off" && !_thinkingStaysOn.has(pair);
|
|
1262
|
+
const mayThink = model.includes("gemini") || model.includes("pro-agent") || (provider === "openai" ? isOpenAIReasoning(model) : !thinkingOff);
|
|
1263
|
+
const effectiveMaxTokens = mayThink ? roomToThink(maxTokens) : maxTokens;
|
|
1264
|
+
let text = null;
|
|
1199
1265
|
try {
|
|
1200
|
-
|
|
1266
|
+
if (provider === "openai") {
|
|
1267
|
+
text = await openaiCall(prompt, model, effectiveMaxTokens, baseURL, request());
|
|
1268
|
+
} else if (!thinkingOff) {
|
|
1269
|
+
text = await anthropicCall(prompt, model, effectiveMaxTokens, baseURL, request());
|
|
1270
|
+
} else {
|
|
1271
|
+
try {
|
|
1272
|
+
text = await anthropicCall(prompt, model, effectiveMaxTokens, baseURL, request(), { type: "disabled" });
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
if (e.status !== 400 || !hasTimeFor(opts.deadline)) throw e;
|
|
1275
|
+
text = await anthropicCall(prompt, model, roomToThink(maxTokens), baseURL, request());
|
|
1276
|
+
_thinkingStaysOn.add(pair);
|
|
1277
|
+
warnOnce(`thinking-on:${pair}`, `[amem] ${model} refused thinking disabled; its fast calls keep thinking`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (text === null) warn(`[amem] ${provider} answered with no text (model ${model})`);
|
|
1201
1281
|
} catch (e) {
|
|
1202
|
-
|
|
1203
|
-
return null;
|
|
1282
|
+
warn(`[amem] LLM call failed: ${e.message}`);
|
|
1204
1283
|
}
|
|
1284
|
+
stopIfForegroundBusy();
|
|
1285
|
+
return text;
|
|
1205
1286
|
}
|
|
1206
|
-
async function anthropicCall(prompt, model, maxTokens, baseURL) {
|
|
1207
|
-
const resp = await anthropic(baseURL).messages.create(
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1287
|
+
async function anthropicCall(prompt, model, maxTokens, baseURL, request, thinking) {
|
|
1288
|
+
const resp = await anthropic(baseURL).messages.create(
|
|
1289
|
+
{
|
|
1290
|
+
model,
|
|
1291
|
+
max_tokens: maxTokens,
|
|
1292
|
+
messages: [{ role: "user", content: prompt }],
|
|
1293
|
+
...thinking && { thinking }
|
|
1294
|
+
},
|
|
1295
|
+
request
|
|
1296
|
+
);
|
|
1212
1297
|
for (const block of resp.content) {
|
|
1213
1298
|
if (block.type === "text") return block.text.trim();
|
|
1214
1299
|
}
|
|
1215
1300
|
return null;
|
|
1216
1301
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1302
|
+
function isOpenAIReasoning(model) {
|
|
1303
|
+
return /^o\d/.test(model) || model.startsWith("gpt-5");
|
|
1304
|
+
}
|
|
1305
|
+
async function openaiCall(prompt, model, maxTokens, baseURL, request) {
|
|
1306
|
+
const resp = await openai(baseURL).chat.completions.create(
|
|
1307
|
+
{
|
|
1308
|
+
model,
|
|
1309
|
+
...isOpenAIReasoning(model) ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens },
|
|
1310
|
+
messages: [{ role: "user", content: prompt }]
|
|
1311
|
+
},
|
|
1312
|
+
request
|
|
1313
|
+
);
|
|
1224
1314
|
return resp.choices[0]?.message?.content?.trim() ?? null;
|
|
1225
1315
|
}
|
|
1226
1316
|
function stripReasoning(raw) {
|
|
@@ -1252,7 +1342,7 @@ function parseJsonLoose(raw) {
|
|
|
1252
1342
|
throw e;
|
|
1253
1343
|
}
|
|
1254
1344
|
}
|
|
1255
|
-
async function llmConstructNote(content) {
|
|
1345
|
+
async function llmConstructNote(content, opts = {}) {
|
|
1256
1346
|
const prompt = `Analyze the following text and respond with valid JSON only (no markdown fences, no explanation, no comments). All string values must use standard double quotes and be properly escaped:
|
|
1257
1347
|
{
|
|
1258
1348
|
"keywords": ["keyword1", "keyword2"],
|
|
@@ -1287,8 +1377,9 @@ confidence guide (Story 27):
|
|
|
1287
1377
|
- low: LLM is uncertain \u2014 vague, fragmentary, or mixed content
|
|
1288
1378
|
|
|
1289
1379
|
Text: ${content}`;
|
|
1290
|
-
const raw = await llmCall(prompt, 400);
|
|
1291
|
-
if (!raw)
|
|
1380
|
+
const raw = await llmCall(prompt, 400, "fast", opts);
|
|
1381
|
+
if (!raw) {
|
|
1382
|
+
warn("[amem] note construction got nothing back; storing with no keywords, tags or context");
|
|
1292
1383
|
return {
|
|
1293
1384
|
keywords: [],
|
|
1294
1385
|
tags: [],
|
|
@@ -1298,6 +1389,7 @@ Text: ${content}`;
|
|
|
1298
1389
|
topics: [],
|
|
1299
1390
|
confidence: "medium"
|
|
1300
1391
|
};
|
|
1392
|
+
}
|
|
1301
1393
|
try {
|
|
1302
1394
|
const data = parseJsonLoose(raw);
|
|
1303
1395
|
const rawCategory = typeof data.category === "string" ? data.category : "General";
|
|
@@ -1316,7 +1408,7 @@ Text: ${content}`;
|
|
|
1316
1408
|
confidence
|
|
1317
1409
|
};
|
|
1318
1410
|
} catch (e) {
|
|
1319
|
-
|
|
1411
|
+
warn(`[amem] Note construction parse failed: ${e.message}`);
|
|
1320
1412
|
return {
|
|
1321
1413
|
keywords: [],
|
|
1322
1414
|
tags: [],
|
|
@@ -1328,26 +1420,32 @@ Text: ${content}`;
|
|
|
1328
1420
|
};
|
|
1329
1421
|
}
|
|
1330
1422
|
}
|
|
1331
|
-
async function llmShouldLink(noteContent, candidateContent) {
|
|
1423
|
+
async function llmShouldLink(noteContent, candidateContent, opts = {}) {
|
|
1332
1424
|
const prompt = `Do these two memory notes have a meaningful relationship that would be useful to link?
|
|
1333
1425
|
Reply with only "yes" or "no".
|
|
1334
1426
|
|
|
1335
1427
|
Note A: ${noteContent}
|
|
1336
1428
|
Note B: ${candidateContent}`;
|
|
1337
|
-
const raw = await llmCall(prompt, 10);
|
|
1429
|
+
const raw = await llmCall(prompt, 10, "fast", opts);
|
|
1338
1430
|
if (!raw) return false;
|
|
1339
1431
|
return raw.toLowerCase().startsWith("yes");
|
|
1340
1432
|
}
|
|
1341
|
-
async function llmCrudDecision(userText, assistantText, existingMemories) {
|
|
1433
|
+
async function llmCrudDecision(userText, assistantText, existingMemories, opts = {}) {
|
|
1342
1434
|
const memoryList = existingMemories.length > 0 ? existingMemories.map((m) => `[${m.idx}] ${m.content}`).join("\n") : "(none)";
|
|
1343
1435
|
const prompt = t.crudDecision(userText.slice(0, 500), assistantText.slice(0, 500), memoryList);
|
|
1344
1436
|
try {
|
|
1345
|
-
const raw = await llmCall(prompt, 400, resolveCrudRole());
|
|
1437
|
+
const raw = await llmCall(prompt, 400, resolveCrudRole(), opts);
|
|
1346
1438
|
if (!raw) return [];
|
|
1347
1439
|
const match = stripReasoning(raw).match(/\[.*\]/s);
|
|
1348
|
-
if (!match)
|
|
1440
|
+
if (!match) {
|
|
1441
|
+
warn("[amem] llmCrudDecision found no array in the response; nothing from this turn is stored");
|
|
1442
|
+
return [];
|
|
1443
|
+
}
|
|
1349
1444
|
const parsed = JSON.parse(match[0]);
|
|
1350
|
-
if (!Array.isArray(parsed))
|
|
1445
|
+
if (!Array.isArray(parsed)) {
|
|
1446
|
+
warn("[amem] llmCrudDecision parsed a non-array; nothing from this turn is stored");
|
|
1447
|
+
return [];
|
|
1448
|
+
}
|
|
1351
1449
|
const ops = [];
|
|
1352
1450
|
for (const item of parsed) {
|
|
1353
1451
|
if (!item || typeof item !== "object") continue;
|
|
@@ -1366,7 +1464,7 @@ async function llmCrudDecision(userText, assistantText, existingMemories) {
|
|
|
1366
1464
|
}
|
|
1367
1465
|
return ops.slice(0, 3);
|
|
1368
1466
|
} catch (e) {
|
|
1369
|
-
|
|
1467
|
+
warn(`[amem] llmCrudDecision failed: ${e.message}`);
|
|
1370
1468
|
return [];
|
|
1371
1469
|
}
|
|
1372
1470
|
}
|
|
@@ -1376,20 +1474,26 @@ async function llmShouldMerge(contentA, contentB) {
|
|
|
1376
1474
|
if (!raw) return { shouldMerge: false };
|
|
1377
1475
|
try {
|
|
1378
1476
|
const data = parseJsonLoose(raw);
|
|
1379
|
-
if (typeof data.shouldMerge !== "boolean")
|
|
1477
|
+
if (typeof data.shouldMerge !== "boolean") {
|
|
1478
|
+
warn("[amem] llmShouldMerge got no boolean verdict; treating the pair as distinct");
|
|
1479
|
+
return { shouldMerge: false };
|
|
1480
|
+
}
|
|
1380
1481
|
if (data.shouldMerge && typeof data.merged === "string") {
|
|
1381
1482
|
return { shouldMerge: true, merged: data.merged };
|
|
1382
1483
|
}
|
|
1383
1484
|
return { shouldMerge: false };
|
|
1384
1485
|
} catch (e) {
|
|
1385
|
-
|
|
1486
|
+
warn(`[amem] llmShouldMerge parse failed: ${e.message}`);
|
|
1386
1487
|
return { shouldMerge: false };
|
|
1387
1488
|
}
|
|
1388
1489
|
}
|
|
1389
1490
|
async function llmEvolutionJudge(oldContent, newContent) {
|
|
1390
1491
|
const prompt = t.evolutionJudge(oldContent, newContent);
|
|
1391
1492
|
const raw = await llmCall(prompt, 300, "strong");
|
|
1392
|
-
if (!raw)
|
|
1493
|
+
if (!raw) {
|
|
1494
|
+
warn("[amem] evolution judge got nothing back; defaulting the pair to NEW");
|
|
1495
|
+
return { type: "NEW" };
|
|
1496
|
+
}
|
|
1393
1497
|
try {
|
|
1394
1498
|
const data = parseJsonLoose(raw);
|
|
1395
1499
|
const type = VALID_EVOLUTION_TYPES.has(data.type) ? data.type : "NEW";
|
|
@@ -1398,11 +1502,11 @@ async function llmEvolutionJudge(oldContent, newContent) {
|
|
|
1398
1502
|
mergedContent: typeof data.mergedContent === "string" ? data.mergedContent : void 0
|
|
1399
1503
|
};
|
|
1400
1504
|
} catch (e) {
|
|
1401
|
-
|
|
1505
|
+
warn(`[amem] llmEvolutionJudge parse failed: ${e.message}`);
|
|
1402
1506
|
return { type: "NEW" };
|
|
1403
1507
|
}
|
|
1404
1508
|
}
|
|
1405
|
-
async function llmEvolveNote(content, linkedNotes) {
|
|
1509
|
+
async function llmEvolveNote(content, linkedNotes, opts = {}) {
|
|
1406
1510
|
const linkedStr = linkedNotes.map((n) => `- ID: ${n.id}
|
|
1407
1511
|
Content: ${n.content}`).join("\n");
|
|
1408
1512
|
const prompt = `A memory note has gained new connections. Update its context, tags, and decide whether to strengthen connections with specific neighbors.
|
|
@@ -1425,7 +1529,7 @@ Original note content: ${content}
|
|
|
1425
1529
|
|
|
1426
1530
|
Newly linked notes (neighbors):
|
|
1427
1531
|
${linkedStr}`;
|
|
1428
|
-
const raw = await llmCall(prompt, 500);
|
|
1532
|
+
const raw = await llmCall(prompt, 500, "fast", opts);
|
|
1429
1533
|
if (!raw) return { tags: null, context: null, shouldStrengthen: false, suggestedConnections: [], tagsToUpdate: [] };
|
|
1430
1534
|
try {
|
|
1431
1535
|
const data = parseJsonLoose(raw);
|
|
@@ -1437,21 +1541,27 @@ ${linkedStr}`;
|
|
|
1437
1541
|
tagsToUpdate: Array.isArray(data.tags_to_update) ? data.tags_to_update.map(String) : []
|
|
1438
1542
|
};
|
|
1439
1543
|
} catch (e) {
|
|
1440
|
-
|
|
1544
|
+
warn(`[amem] Evolution parse failed: ${e.message}`);
|
|
1441
1545
|
return { tags: null, context: null, shouldStrengthen: false, suggestedConnections: [], tagsToUpdate: [] };
|
|
1442
1546
|
}
|
|
1443
1547
|
}
|
|
1444
1548
|
async function llmConflictScan(contents) {
|
|
1445
1549
|
if (contents.length < 2) return [];
|
|
1446
1550
|
const numbered = contents.map((c, i) => `[${i}] ${c}`).join("\n");
|
|
1551
|
+
const raw = await llmCall(t.conflictScan(numbered), 600, "strong");
|
|
1552
|
+
if (!raw) return null;
|
|
1447
1553
|
try {
|
|
1448
|
-
const raw = await llmCall(t.conflictScan(numbered), 600, "strong");
|
|
1449
|
-
if (!raw) return [];
|
|
1450
1554
|
const cleaned = stripReasoning(raw);
|
|
1451
1555
|
const match = cleaned.match(/\[[\s\S]*\]/);
|
|
1452
|
-
if (!match)
|
|
1556
|
+
if (!match) {
|
|
1557
|
+
warn("[amem] llmConflictScan found no array in the response; the batch is read again next run");
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1453
1560
|
const parsed = JSON.parse(match[0]);
|
|
1454
|
-
if (!Array.isArray(parsed))
|
|
1561
|
+
if (!Array.isArray(parsed)) {
|
|
1562
|
+
warn("[amem] llmConflictScan parsed a non-array; the batch is read again next run");
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1455
1565
|
const pairs = [];
|
|
1456
1566
|
const seen = /* @__PURE__ */ new Set();
|
|
1457
1567
|
for (const item of parsed) {
|
|
@@ -1473,21 +1583,30 @@ async function llmConflictScan(contents) {
|
|
|
1473
1583
|
supersededIndex
|
|
1474
1584
|
});
|
|
1475
1585
|
}
|
|
1586
|
+
if (parsed.length > 0 && pairs.length === 0) {
|
|
1587
|
+
warn("[amem] llmConflictScan got no valid pair in the response; the batch is read again next run");
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1476
1590
|
return pairs;
|
|
1477
1591
|
} catch (e) {
|
|
1478
|
-
|
|
1479
|
-
return
|
|
1592
|
+
warn(`[amem] llmConflictScan failed: ${e.message}`);
|
|
1593
|
+
return null;
|
|
1480
1594
|
}
|
|
1481
1595
|
}
|
|
1482
|
-
var import_sdk, import_openai, _override, _warned, DEFAULT_TIMEOUT_MS, _anthropicClients, _openaiClients, VALID_CONFIDENCE, VALID_CATEGORIES, VALID_EVOLUTION_TYPES;
|
|
1596
|
+
var import_sdk, import_openai, _override, _warned, _thinkingStaysOn, roomToThink, DEFAULT_TIMEOUT_MS, _anthropicClients, _openaiClients, VALID_CONFIDENCE, VALID_CATEGORIES, VALID_EVOLUTION_TYPES;
|
|
1483
1597
|
var init_llm = __esm({
|
|
1484
1598
|
"../amem-core/src/llm.ts"() {
|
|
1485
1599
|
"use strict";
|
|
1486
1600
|
import_sdk = __toESM(require("@anthropic-ai/sdk"), 1);
|
|
1487
1601
|
import_openai = __toESM(require("openai"), 1);
|
|
1488
1602
|
init_prompts();
|
|
1603
|
+
init_config();
|
|
1604
|
+
init_deadline();
|
|
1605
|
+
init_background();
|
|
1489
1606
|
_override = {};
|
|
1490
1607
|
_warned = /* @__PURE__ */ new Set();
|
|
1608
|
+
_thinkingStaysOn = /* @__PURE__ */ new Set();
|
|
1609
|
+
roomToThink = (maxTokens) => Math.max(maxTokens * 8, 4e3);
|
|
1491
1610
|
DEFAULT_TIMEOUT_MS = 3e4;
|
|
1492
1611
|
_anthropicClients = /* @__PURE__ */ new Map();
|
|
1493
1612
|
_openaiClients = /* @__PURE__ */ new Map();
|
|
@@ -1624,7 +1743,8 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1624
1743
|
return existingByHash.id;
|
|
1625
1744
|
}
|
|
1626
1745
|
console.log("[add] Constructing note...");
|
|
1627
|
-
const
|
|
1746
|
+
const deadline = opts?.deadline;
|
|
1747
|
+
const { keywords, tags, context, category, note_type, topics } = await llmConstructNote(content, { deadline });
|
|
1628
1748
|
console.log(` keywords: ${keywords.join(", ")}`);
|
|
1629
1749
|
console.log(` tags: ${tags.join(", ")}`);
|
|
1630
1750
|
console.log(` context: ${context}`);
|
|
@@ -1697,8 +1817,9 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1697
1817
|
for (const { note: cand, score } of candidates) {
|
|
1698
1818
|
if (cand.id === note.id) continue;
|
|
1699
1819
|
if (score < 0.3) continue;
|
|
1820
|
+
if (!hasTimeFor(deadline)) break;
|
|
1700
1821
|
console.log(` candidate ${cand.id.slice(0, 8)}... sim=${score.toFixed(3)}, asking LLM...`);
|
|
1701
|
-
const shouldLink = await llmShouldLink(content, cand.content);
|
|
1822
|
+
const shouldLink = await llmShouldLink(content, cand.content, { deadline });
|
|
1702
1823
|
if (shouldLink) {
|
|
1703
1824
|
linkedIds.push(cand.id);
|
|
1704
1825
|
linkedContents.push(cand.content);
|
|
@@ -1722,6 +1843,7 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1722
1843
|
if (shouldRunEvolution()) {
|
|
1723
1844
|
console.log(` [evo] threshold reached, running evolution for ${Math.min(linkedIds.length, 3)} linked notes`);
|
|
1724
1845
|
for (const lid of linkedIds.slice(0, 3)) {
|
|
1846
|
+
if (!hasTimeFor(deadline)) break;
|
|
1725
1847
|
const linked = await ctx.getNote(lid, agentId);
|
|
1726
1848
|
if (!linked) continue;
|
|
1727
1849
|
if (!canWrite(linked, agentId)) {
|
|
@@ -1743,7 +1865,7 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1743
1865
|
shouldStrengthen,
|
|
1744
1866
|
suggestedConnections,
|
|
1745
1867
|
tagsToUpdate
|
|
1746
|
-
} = await llmEvolveNote(linked.content, linkedNotes);
|
|
1868
|
+
} = await llmEvolveNote(linked.content, linkedNotes, { deadline });
|
|
1747
1869
|
let evolved = false;
|
|
1748
1870
|
if (newTags !== null || newContext !== null) {
|
|
1749
1871
|
if (newTags !== null) linked.tags = newTags;
|
|
@@ -1817,7 +1939,7 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1817
1939
|
}
|
|
1818
1940
|
}
|
|
1819
1941
|
} catch (e) {
|
|
1820
|
-
|
|
1942
|
+
warn(`[amem] link/evolution phase failed: ${e.message}`);
|
|
1821
1943
|
}
|
|
1822
1944
|
console.log(`[done] Note added: ${note.id}`);
|
|
1823
1945
|
return note.id;
|
|
@@ -1964,10 +2086,9 @@ async function listMemories(agentId = "main", storageCtx) {
|
|
|
1964
2086
|
function sleep(ms) {
|
|
1965
2087
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1966
2088
|
}
|
|
1967
|
-
async function mergeSimilarNotes(agentId, storageCtx) {
|
|
2089
|
+
async function mergeSimilarNotes(agentId, storageCtx, date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) {
|
|
1968
2090
|
const ctx = storageCtx ?? defaultCtx();
|
|
1969
|
-
const
|
|
1970
|
-
const allNotes = await ctx.getNotesByDatePrefix(today, agentId);
|
|
2091
|
+
const allNotes = await ctx.getNotesByDatePrefix(date, agentId);
|
|
1971
2092
|
const notes = allNotes.filter((n) => n.agent_id !== "shared");
|
|
1972
2093
|
const pendingNotes = notes.filter((n) => n.pending_merge === true);
|
|
1973
2094
|
let evolvedCount = 0;
|
|
@@ -1985,6 +2106,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
|
|
|
1985
2106
|
}
|
|
1986
2107
|
}
|
|
1987
2108
|
if (!bestNeighbor) {
|
|
2109
|
+
stopIfForegroundBusy();
|
|
1988
2110
|
await ctx.patchNotePayload(pendingNote.id, { pending_merge: false });
|
|
1989
2111
|
continue;
|
|
1990
2112
|
}
|
|
@@ -2083,8 +2205,8 @@ async function consolidateMemories(agentId, logger, storageCtx) {
|
|
|
2083
2205
|
const ctx = storageCtx ?? defaultCtx();
|
|
2084
2206
|
const log = {
|
|
2085
2207
|
info: (msg) => logger ? logger.info(msg) : console.log(msg),
|
|
2086
|
-
warn: (msg) => logger ? logger.warn(msg) :
|
|
2087
|
-
error: (msg) => logger ? logger.error(msg) :
|
|
2208
|
+
warn: (msg) => logger ? logger.warn(msg) : warn(msg),
|
|
2209
|
+
error: (msg) => logger ? logger.error(msg) : warn(msg)
|
|
2088
2210
|
};
|
|
2089
2211
|
log.info(`[Consolidation] Starting consolidation for agentId: ${agentId}`);
|
|
2090
2212
|
const rawNotes = await ctx.listNotes(agentId);
|
|
@@ -2105,6 +2227,7 @@ async function consolidateMemories(agentId, logger, storageCtx) {
|
|
|
2105
2227
|
for (const [category, groupNotes] of groups.entries()) {
|
|
2106
2228
|
log.info(`[Consolidation] Category "${category}" has ${groupNotes.length} notes.`);
|
|
2107
2229
|
for (let i = 0; i < groupNotes.length; i++) {
|
|
2230
|
+
await pauseForForeground();
|
|
2108
2231
|
for (let j = i + 1; j < groupNotes.length; j++) {
|
|
2109
2232
|
const noteA = groupNotes[i];
|
|
2110
2233
|
const noteB = groupNotes[j];
|
|
@@ -2214,6 +2337,7 @@ async function conflictSweep(agentId, opts) {
|
|
|
2214
2337
|
let retired = 0;
|
|
2215
2338
|
let batchesScanned = 0;
|
|
2216
2339
|
let batchesSkipped = 0;
|
|
2340
|
+
let batchesFailed = 0;
|
|
2217
2341
|
for (const [category, groupNotes] of groups.entries()) {
|
|
2218
2342
|
groupNotes.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
2219
2343
|
for (let start = 0; start < groupNotes.length; start += CONFLICT_BATCH_SIZE) {
|
|
@@ -2225,6 +2349,10 @@ async function conflictSweep(agentId, opts) {
|
|
|
2225
2349
|
}
|
|
2226
2350
|
batchesScanned++;
|
|
2227
2351
|
const pairs = await llmConflictScan(batch.map((n) => n.content));
|
|
2352
|
+
if (pairs === null) {
|
|
2353
|
+
batchesFailed++;
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2228
2356
|
for (const { a, b, reason, supersededIndex } of pairs) {
|
|
2229
2357
|
const noteA = batch[a];
|
|
2230
2358
|
const noteB = batch[b];
|
|
@@ -2263,9 +2391,9 @@ async function conflictSweep(agentId, opts) {
|
|
|
2263
2391
|
}
|
|
2264
2392
|
}
|
|
2265
2393
|
log(
|
|
2266
|
-
`[conflict] ${batchesScanned} batch(es) scanned, ${batchesSkipped} already up to date
|
|
2394
|
+
`[conflict] ${batchesScanned} batch(es) scanned, ${batchesSkipped} already up to date` + (batchesFailed > 0 ? `, ${batchesFailed} with no answer (read again next run)` : "") + `; ${pairsFound} pair(s) found, ${retired} retired`
|
|
2267
2395
|
);
|
|
2268
|
-
return { scanned: notes.length, pairsFound, retired, batchesScanned, batchesSkipped };
|
|
2396
|
+
return { scanned: notes.length, pairsFound, retired, batchesScanned, batchesSkipped, batchesFailed };
|
|
2269
2397
|
}
|
|
2270
2398
|
var import_uuid, import_crypto, fs2, path3, import_jieba, logSafe, _jieba, EPHEMERAL_SIGNALS, CONFLICT_BATCH_SIZE;
|
|
2271
2399
|
var init_memory = __esm({
|
|
@@ -2280,6 +2408,8 @@ var init_memory = __esm({
|
|
|
2280
2408
|
init_auth();
|
|
2281
2409
|
init_llm();
|
|
2282
2410
|
init_evo_counter();
|
|
2411
|
+
init_deadline();
|
|
2412
|
+
init_background();
|
|
2283
2413
|
init_config();
|
|
2284
2414
|
import_jieba = require("@node-rs/jieba");
|
|
2285
2415
|
logSafe = (id) => id.replace(/[\r\n]/g, "");
|
|
@@ -2488,7 +2618,7 @@ async function migrateCollection(opts) {
|
|
|
2488
2618
|
const refreshFields = opts.refreshFields !== false;
|
|
2489
2619
|
const dryRun = opts.dryRun !== false;
|
|
2490
2620
|
const log = opts.logger?.info ?? ((m) => console.log(m));
|
|
2491
|
-
const
|
|
2621
|
+
const warn2 = opts.logger?.warn ?? warn;
|
|
2492
2622
|
if (from === to) throw new Error(`migrate: source and target are the same collection ("${from}")`);
|
|
2493
2623
|
const model = getEmbeddingModel();
|
|
2494
2624
|
const targetDim = await getEmbeddingDim();
|
|
@@ -2556,7 +2686,7 @@ async function migrateCollection(opts) {
|
|
|
2556
2686
|
if (!note.context) note.context = built.context;
|
|
2557
2687
|
refreshed++;
|
|
2558
2688
|
} catch (e) {
|
|
2559
|
-
|
|
2689
|
+
warn2(`[migrate] re-extract failed for ${note.id.slice(0, 8)} \u2014 keeping as-is: ${e.message}`);
|
|
2560
2690
|
}
|
|
2561
2691
|
}
|
|
2562
2692
|
const point = noteToPoint({ ...note, embedding: await encode(buildEmbedText(note)) });
|
|
@@ -2569,7 +2699,7 @@ async function migrateCollection(opts) {
|
|
|
2569
2699
|
await flush();
|
|
2570
2700
|
const finalCount = await countPointsRaw(to);
|
|
2571
2701
|
if (finalCount !== notes.length) {
|
|
2572
|
-
|
|
2702
|
+
warn2(`[migrate] target holds ${finalCount} point(s) but the source had ${notes.length} \u2014 check before switching`);
|
|
2573
2703
|
}
|
|
2574
2704
|
log(`[migrate] done: ${migrated} written, ${alreadyDone.size} already present, ${refreshed} re-extracted.`);
|
|
2575
2705
|
return {
|
|
@@ -2624,6 +2754,7 @@ var init_migrate = __esm({
|
|
|
2624
2754
|
init_embedding();
|
|
2625
2755
|
init_llm();
|
|
2626
2756
|
init_memory();
|
|
2757
|
+
init_config();
|
|
2627
2758
|
}
|
|
2628
2759
|
});
|
|
2629
2760
|
|
|
@@ -2651,6 +2782,7 @@ var init_crud_guard = __esm({
|
|
|
2651
2782
|
// ../amem-core/src/index.ts
|
|
2652
2783
|
var src_exports = {};
|
|
2653
2784
|
__export(src_exports, {
|
|
2785
|
+
BackgroundPreempted: () => BackgroundPreempted,
|
|
2654
2786
|
DEFAULT_CRUD_UPDATE_MIN_SIM: () => DEFAULT_CRUD_UPDATE_MIN_SIM,
|
|
2655
2787
|
DEFAULT_EMBEDDING_MODEL: () => DEFAULT_EMBEDDING_MODEL,
|
|
2656
2788
|
EmbeddingDimensionMismatchError: () => EmbeddingDimensionMismatchError,
|
|
@@ -2679,6 +2811,7 @@ __export(src_exports, {
|
|
|
2679
2811
|
getEmbeddingModel: () => getEmbeddingModel,
|
|
2680
2812
|
getEmbeddingPooling: () => getEmbeddingPooling,
|
|
2681
2813
|
getNote: () => getNote,
|
|
2814
|
+
hasTimeFor: () => hasTimeFor,
|
|
2682
2815
|
invalidateNote: () => invalidateNote,
|
|
2683
2816
|
isModelLoaded: () => isModelLoaded,
|
|
2684
2817
|
isPlausibleUpdateTarget: () => isPlausibleUpdateTarget,
|
|
@@ -2691,6 +2824,7 @@ __export(src_exports, {
|
|
|
2691
2824
|
patchNotePayload: () => patchNotePayload,
|
|
2692
2825
|
pingQdrant: () => pingQdrant,
|
|
2693
2826
|
resolveCrudUpdateMinSim: () => resolveCrudUpdateMinSim,
|
|
2827
|
+
runInBackground: () => runInBackground,
|
|
2694
2828
|
scanLowQuality: () => scanLowQuality,
|
|
2695
2829
|
searchMemory: () => searchMemory,
|
|
2696
2830
|
switchToMigrated: () => switchToMigrated,
|
|
@@ -2711,6 +2845,8 @@ var init_src = __esm({
|
|
|
2711
2845
|
init_memory();
|
|
2712
2846
|
init_crud_guard();
|
|
2713
2847
|
init_llm();
|
|
2848
|
+
init_deadline();
|
|
2849
|
+
init_background();
|
|
2714
2850
|
}
|
|
2715
2851
|
});
|
|
2716
2852
|
|
|
@@ -2737,7 +2873,7 @@ __export(index_exports, {
|
|
|
2737
2873
|
});
|
|
2738
2874
|
module.exports = __toCommonJS(index_exports);
|
|
2739
2875
|
var os2 = __toESM(require("os"));
|
|
2740
|
-
var
|
|
2876
|
+
var path6 = __toESM(require("path"));
|
|
2741
2877
|
var import_plugin_entry = require("openclaw/plugin-sdk/plugin-entry");
|
|
2742
2878
|
init_src();
|
|
2743
2879
|
var import_crypto2 = require("crypto");
|
|
@@ -2750,6 +2886,237 @@ function isConvAccessBlocked(config, pluginId) {
|
|
|
2750
2886
|
return entry !== void 0 && entry.hooks?.allowConversationAccess !== true;
|
|
2751
2887
|
}
|
|
2752
2888
|
|
|
2889
|
+
// src/nightly.ts
|
|
2890
|
+
var fs4 = __toESM(require("fs"));
|
|
2891
|
+
var path5 = __toESM(require("path"));
|
|
2892
|
+
var SLOT = /* @__PURE__ */ Symbol.for("openclaw-amem.nightly-timer");
|
|
2893
|
+
function msUntil(hour, minute, now = /* @__PURE__ */ new Date()) {
|
|
2894
|
+
const target = new Date(now);
|
|
2895
|
+
target.setHours(hour, minute, 0, 0);
|
|
2896
|
+
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
|
|
2897
|
+
return target.getTime() - now.getTime();
|
|
2898
|
+
}
|
|
2899
|
+
function scheduleNightly(job, onError) {
|
|
2900
|
+
const holder = globalThis;
|
|
2901
|
+
if (holder[SLOT]) clearTimeout(holder[SLOT]);
|
|
2902
|
+
const timer = setTimeout(
|
|
2903
|
+
async () => {
|
|
2904
|
+
try {
|
|
2905
|
+
await job();
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
onError(err);
|
|
2908
|
+
} finally {
|
|
2909
|
+
if (holder[SLOT] === timer) scheduleNightly(job, onError);
|
|
2910
|
+
}
|
|
2911
|
+
},
|
|
2912
|
+
msUntil(2, 30)
|
|
2913
|
+
);
|
|
2914
|
+
timer.unref();
|
|
2915
|
+
holder[SLOT] = timer;
|
|
2916
|
+
}
|
|
2917
|
+
function cancelNightly() {
|
|
2918
|
+
const holder = globalThis;
|
|
2919
|
+
if (holder[SLOT]) clearTimeout(holder[SLOT]);
|
|
2920
|
+
delete holder[SLOT];
|
|
2921
|
+
}
|
|
2922
|
+
function readFile(file) {
|
|
2923
|
+
try {
|
|
2924
|
+
const data = JSON.parse(fs4.readFileSync(file, "utf-8"));
|
|
2925
|
+
const agents = {};
|
|
2926
|
+
for (const [id, entry] of Object.entries(data.agents ?? {})) {
|
|
2927
|
+
if (typeof entry?.from === "string" && typeof entry.wrote === "string") {
|
|
2928
|
+
agents[id] = { from: entry.from, wrote: entry.wrote };
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
return { agents };
|
|
2932
|
+
} catch {
|
|
2933
|
+
return { agents: {} };
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
function writeFile(file, owed) {
|
|
2937
|
+
fs4.mkdirSync(path5.dirname(file), { recursive: true });
|
|
2938
|
+
fs4.writeFileSync(file, JSON.stringify(owed));
|
|
2939
|
+
}
|
|
2940
|
+
function markOwed(file, rawAgentId, since, now = /* @__PURE__ */ new Date()) {
|
|
2941
|
+
const owed = readFile(file);
|
|
2942
|
+
owed.agents[rawAgentId] = {
|
|
2943
|
+
from: owed.agents[rawAgentId]?.from ?? since.toISOString(),
|
|
2944
|
+
wrote: now.toISOString()
|
|
2945
|
+
};
|
|
2946
|
+
writeFile(file, owed);
|
|
2947
|
+
}
|
|
2948
|
+
function readOwed(file) {
|
|
2949
|
+
return new Map(Object.entries(readFile(file).agents).map(([id, entry]) => [id, new Date(entry.from)]));
|
|
2950
|
+
}
|
|
2951
|
+
function settleAgent(file, rawAgentId, startedAt) {
|
|
2952
|
+
const owed = readFile(file);
|
|
2953
|
+
const entry = owed.agents[rawAgentId];
|
|
2954
|
+
if (!entry) return;
|
|
2955
|
+
if (Date.parse(entry.wrote) < startedAt.getTime()) delete owed.agents[rawAgentId];
|
|
2956
|
+
else entry.from = startedAt.toISOString();
|
|
2957
|
+
writeFile(file, owed);
|
|
2958
|
+
}
|
|
2959
|
+
function utcDatesSince(since, now, maxDays = 7) {
|
|
2960
|
+
const DAY = 864e5;
|
|
2961
|
+
const utcMidnight = (t2) => t2 - t2 % DAY;
|
|
2962
|
+
const last = utcMidnight(now.getTime());
|
|
2963
|
+
const earliest = last - (maxDays - 1) * DAY;
|
|
2964
|
+
const first = Math.max(utcMidnight(since ? since.getTime() : now.getTime() - DAY), earliest);
|
|
2965
|
+
const days = [];
|
|
2966
|
+
for (let t2 = first; t2 <= last; t2 += DAY) days.push(new Date(t2).toISOString().slice(0, 10));
|
|
2967
|
+
return days;
|
|
2968
|
+
}
|
|
2969
|
+
var FOREGROUND = /* @__PURE__ */ Symbol.for("openclaw-amem.foreground");
|
|
2970
|
+
var STALE_MS = 30 * 6e4;
|
|
2971
|
+
var QUIET_MS = 6e4;
|
|
2972
|
+
var NIGHTLY_WAIT_CAP_MS = 60 * 6e4;
|
|
2973
|
+
var MAX_ATTEMPTS = 3;
|
|
2974
|
+
function foreground() {
|
|
2975
|
+
const holder = globalThis;
|
|
2976
|
+
return holder[FOREGROUND] ??= { inFlight: /* @__PURE__ */ new Map(), lastActivity: 0, events: 0 };
|
|
2977
|
+
}
|
|
2978
|
+
function touch(fg, now) {
|
|
2979
|
+
fg.lastActivity = now;
|
|
2980
|
+
fg.events++;
|
|
2981
|
+
}
|
|
2982
|
+
var compactionKey = (session) => `compaction:${session}`;
|
|
2983
|
+
function noteAgentEvent(evt, now = Date.now()) {
|
|
2984
|
+
const fg = foreground();
|
|
2985
|
+
touch(fg, now);
|
|
2986
|
+
if (!evt.runId) return;
|
|
2987
|
+
const phase = evt.stream === "lifecycle" ? evt.data?.phase : void 0;
|
|
2988
|
+
if (phase === "start") {
|
|
2989
|
+
fg.inFlight.set(evt.runId, now);
|
|
2990
|
+
} else if (phase === "end" || phase === "error") {
|
|
2991
|
+
fg.inFlight.delete(evt.runId);
|
|
2992
|
+
for (const session of [evt.sessionKey, evt.sessionId]) if (session) fg.inFlight.delete(compactionKey(session));
|
|
2993
|
+
} else if (fg.inFlight.has(evt.runId)) {
|
|
2994
|
+
fg.inFlight.set(evt.runId, now);
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
function watchAgentEvents(subscribe) {
|
|
2998
|
+
const fg = foreground();
|
|
2999
|
+
fg.unsubscribe?.();
|
|
3000
|
+
fg.unsubscribe = subscribe((evt) => noteAgentEvent(evt));
|
|
3001
|
+
}
|
|
3002
|
+
function unwatchAgentEvents() {
|
|
3003
|
+
const fg = foreground();
|
|
3004
|
+
fg.unsubscribe?.();
|
|
3005
|
+
delete fg.unsubscribe;
|
|
3006
|
+
}
|
|
3007
|
+
function foregroundWorkStarted(now = Date.now()) {
|
|
3008
|
+
const fg = foreground();
|
|
3009
|
+
const token = /* @__PURE__ */ Symbol("agent_end");
|
|
3010
|
+
fg.inFlight.set(token, now);
|
|
3011
|
+
touch(fg, now);
|
|
3012
|
+
return () => {
|
|
3013
|
+
fg.inFlight.delete(token);
|
|
3014
|
+
touch(fg, Date.now());
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
3017
|
+
function compactionStarted(ctx, now = Date.now()) {
|
|
3018
|
+
const fg = foreground();
|
|
3019
|
+
touch(fg, now);
|
|
3020
|
+
const session = ctx?.sessionKey ?? ctx?.sessionId;
|
|
3021
|
+
if (session) fg.inFlight.set(compactionKey(session), now);
|
|
3022
|
+
}
|
|
3023
|
+
function compactionEnded(ctx, now = Date.now()) {
|
|
3024
|
+
const fg = foreground();
|
|
3025
|
+
touch(fg, now);
|
|
3026
|
+
const session = ctx?.sessionKey ?? ctx?.sessionId;
|
|
3027
|
+
if (session) fg.inFlight.delete(compactionKey(session));
|
|
3028
|
+
}
|
|
3029
|
+
function noteMessageReceived(event, now = Date.now()) {
|
|
3030
|
+
const update = event?.providerUpdate;
|
|
3031
|
+
if (update?.editedTimestamp !== void 0 || update?.kind?.startsWith("edited_")) return;
|
|
3032
|
+
touch(foreground(), now);
|
|
3033
|
+
}
|
|
3034
|
+
function foregroundBusy(now = Date.now()) {
|
|
3035
|
+
const { inFlight } = foreground();
|
|
3036
|
+
for (const [key, seen] of inFlight) if (now - seen > STALE_MS) inFlight.delete(key);
|
|
3037
|
+
return inFlight.size > 0;
|
|
3038
|
+
}
|
|
3039
|
+
function busySinceNow() {
|
|
3040
|
+
const fg = foreground();
|
|
3041
|
+
const seen = fg.events;
|
|
3042
|
+
return () => foregroundBusy() || fg.events !== seen;
|
|
3043
|
+
}
|
|
3044
|
+
function makeIdleWaiter(pollMs = 15e3, capMs = NIGHTLY_WAIT_CAP_MS) {
|
|
3045
|
+
let waited = 0;
|
|
3046
|
+
return {
|
|
3047
|
+
async untilIdle() {
|
|
3048
|
+
for (; ; ) {
|
|
3049
|
+
const now = Date.now();
|
|
3050
|
+
if (!foregroundBusy(now) && now - foreground().lastActivity >= QUIET_MS) return true;
|
|
3051
|
+
if (waited >= capMs) return false;
|
|
3052
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollMs).unref());
|
|
3053
|
+
waited += Date.now() - now;
|
|
3054
|
+
}
|
|
3055
|
+
},
|
|
3056
|
+
waitedMs: () => waited
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
async function runStep(idle, attempt, preempted, onPreempted) {
|
|
3060
|
+
for (let tries = 1; ; tries++) {
|
|
3061
|
+
if (!await idle.untilIdle()) return "out of waiting";
|
|
3062
|
+
try {
|
|
3063
|
+
await attempt();
|
|
3064
|
+
return "done";
|
|
3065
|
+
} catch (err) {
|
|
3066
|
+
if (!preempted(err)) throw err;
|
|
3067
|
+
if (tries >= MAX_ATTEMPTS) return "stopped too often";
|
|
3068
|
+
onPreempted();
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
async function runNightly(work, startedAt = /* @__PURE__ */ new Date()) {
|
|
3073
|
+
const { logger } = work;
|
|
3074
|
+
const idle = work.idle ?? makeIdleWaiter();
|
|
3075
|
+
let stopped = false;
|
|
3076
|
+
const step = async (name, run) => {
|
|
3077
|
+
if (stopped) return false;
|
|
3078
|
+
const result = await runStep(
|
|
3079
|
+
idle,
|
|
3080
|
+
() => work.inBackground(run),
|
|
3081
|
+
work.preempted,
|
|
3082
|
+
() => logger.info(`openclaw-amem: nightly ${name} stopped for a running task; it runs again when the gateway is idle`)
|
|
3083
|
+
);
|
|
3084
|
+
if (result === "done") return true;
|
|
3085
|
+
stopped = true;
|
|
3086
|
+
logger.warn(
|
|
3087
|
+
result === "out of waiting" ? `openclaw-amem: nightly job waited ${Math.round(idle.waitedMs() / 6e4)} min for running tasks; the rest runs tomorrow` : `openclaw-amem: nightly ${name} was stopped ${MAX_ATTEMPTS} times by running tasks; the rest runs tomorrow`
|
|
3088
|
+
);
|
|
3089
|
+
return false;
|
|
3090
|
+
};
|
|
3091
|
+
try {
|
|
3092
|
+
const owed = readOwed(work.owedFile);
|
|
3093
|
+
merging: for (const agent of /* @__PURE__ */ new Set([work.defaultAgent, ...owed.keys()])) {
|
|
3094
|
+
let failed = false;
|
|
3095
|
+
for (const day of utcDatesSince(owed.get(agent), startedAt)) {
|
|
3096
|
+
try {
|
|
3097
|
+
if (!await step(`merge (${agent}, ${day})`, () => work.mergeDay(agent, day))) break merging;
|
|
3098
|
+
} catch (err) {
|
|
3099
|
+
failed = true;
|
|
3100
|
+
logger.warn(`openclaw-amem: merge failed (${agent}, ${day}) \u2014 ${err.message}`);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (!failed) settleAgent(work.owedFile, agent, startedAt);
|
|
3104
|
+
}
|
|
3105
|
+
} catch (err) {
|
|
3106
|
+
logger.warn(`openclaw-amem: nightly merge failed \u2014 ${err.message}`);
|
|
3107
|
+
}
|
|
3108
|
+
for (const { name, run } of work.after) {
|
|
3109
|
+
try {
|
|
3110
|
+
await step(name, run);
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
logger.warn(`openclaw-amem: nightly ${name} failed \u2014 ${err.message}`);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
if (idle.waitedMs() > 0) {
|
|
3116
|
+
logger.info(`openclaw-amem: nightly job waited ${Math.round(idle.waitedMs() / 1e3)} s for running tasks`);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
|
|
2753
3120
|
// src/scope.ts
|
|
2754
3121
|
function parseAgentIdFromSessionKey(sessionKey) {
|
|
2755
3122
|
if (!sessionKey) return void 0;
|
|
@@ -2775,6 +3142,8 @@ function buildScope(rawAgentId, pluginConfig, createStorageContext2) {
|
|
|
2775
3142
|
// src/index.ts
|
|
2776
3143
|
init_src();
|
|
2777
3144
|
var _config = {};
|
|
3145
|
+
var HOOK_BUDGET_MS = 3e4;
|
|
3146
|
+
var HOOK_MARGIN_MS = 2e3;
|
|
2778
3147
|
function register(api) {
|
|
2779
3148
|
const logger = api.logger;
|
|
2780
3149
|
_config = api.pluginConfig || {};
|
|
@@ -2782,14 +3151,18 @@ function register(api) {
|
|
|
2782
3151
|
const pluginId = api.id ?? "openclaw-amem";
|
|
2783
3152
|
const convBlocked = isConvAccessBlocked(api.config, pluginId);
|
|
2784
3153
|
if (convBlocked) logger.warn(BLOCKED_WARNING_LOG);
|
|
2785
|
-
configure({
|
|
3154
|
+
configure({
|
|
3155
|
+
dataDir: path6.join(os2.homedir(), ".openclaw"),
|
|
3156
|
+
warn: (msg) => logger.warn(msg)
|
|
3157
|
+
});
|
|
2786
3158
|
const hasStrong = !!(pluginConfig.llmStrongProvider || pluginConfig.llmStrongModel || pluginConfig.llmStrongBaseURL);
|
|
2787
|
-
if (pluginConfig.llmProvider || pluginConfig.llmModel || pluginConfig.llmBaseURL || pluginConfig.llmCrudRole || hasStrong) {
|
|
3159
|
+
if (pluginConfig.llmProvider || pluginConfig.llmModel || pluginConfig.llmBaseURL || pluginConfig.llmCrudRole || pluginConfig.llmThinking || hasStrong) {
|
|
2788
3160
|
configureLlm({
|
|
2789
3161
|
provider: pluginConfig.llmProvider,
|
|
2790
3162
|
model: pluginConfig.llmModel,
|
|
2791
3163
|
baseURL: pluginConfig.llmBaseURL,
|
|
2792
3164
|
crudRole: pluginConfig.llmCrudRole,
|
|
3165
|
+
thinking: pluginConfig.llmThinking,
|
|
2793
3166
|
// Omit the whole block when unset so `strong` transparently falls back to
|
|
2794
3167
|
// `fast` — the zero-config path stays byte-for-byte today's behaviour.
|
|
2795
3168
|
...hasStrong && {
|
|
@@ -2806,7 +3179,15 @@ function register(api) {
|
|
|
2806
3179
|
const resolveAgentId2 = (ctx) => resolveAgentId(ctx, pluginConfig);
|
|
2807
3180
|
const buildScope2 = (rawAgentId) => buildScope(rawAgentId, pluginConfig, createStorageContext);
|
|
2808
3181
|
const defaultScope = buildScope2(resolveAgentId2());
|
|
2809
|
-
const dbPath =
|
|
3182
|
+
const dbPath = path6.join(os2.homedir(), ".openclaw", "amem_db");
|
|
3183
|
+
const owedFile = path6.join(os2.homedir(), ".openclaw", "amem_nightly_owed.json");
|
|
3184
|
+
const owe = (rawAgentId, since) => {
|
|
3185
|
+
try {
|
|
3186
|
+
markOwed(owedFile, rawAgentId, since);
|
|
3187
|
+
} catch (err) {
|
|
3188
|
+
logger.warn(`openclaw-amem: could not record ${rawAgentId} for the nightly merge \u2014 ${err.message}`);
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
2810
3191
|
logger.info(
|
|
2811
3192
|
`openclaw-amem: registered (native TS, Qdrant, default agent_id=${defaultScope.agentId}, default collection=${pluginConfig.collection ?? "amem_notes (default)"}, per-agent scope resolved per call)`
|
|
2812
3193
|
);
|
|
@@ -2860,7 +3241,9 @@ function register(api) {
|
|
|
2860
3241
|
},
|
|
2861
3242
|
async add(text) {
|
|
2862
3243
|
try {
|
|
3244
|
+
const since = /* @__PURE__ */ new Date();
|
|
2863
3245
|
await addMemory(text, scope.agentId, { storageCtx: scope.storageCtx });
|
|
3246
|
+
owe(resolveAgentId2(params), since);
|
|
2864
3247
|
return { ok: true };
|
|
2865
3248
|
} catch (err) {
|
|
2866
3249
|
logger.warn(`openclaw-amem: add failed \u2014 ${err.message}`);
|
|
@@ -2981,6 +3364,7 @@ ${text}${hookWarning}` }],
|
|
|
2981
3364
|
const start = Date.now();
|
|
2982
3365
|
try {
|
|
2983
3366
|
const id = await addMemory(text, scope.agentId, { subjects, storageCtx: scope.storageCtx });
|
|
3367
|
+
owe(resolveAgentId2(ctx), new Date(start));
|
|
2984
3368
|
logger.info(`openclaw-amem: memory_add OK id=${id} (${Date.now() - start}ms)`);
|
|
2985
3369
|
return {
|
|
2986
3370
|
content: [{ type: "text", text: "Memory saved successfully." }],
|
|
@@ -3107,15 +3491,30 @@ ${text}${hookWarning}` }],
|
|
|
3107
3491
|
}
|
|
3108
3492
|
if (typeof api.registerHook === "function" || typeof api.on === "function") {
|
|
3109
3493
|
const hookFn = (typeof api.on === "function" ? api.on : api.registerHook).bind(api);
|
|
3494
|
+
hookFn(
|
|
3495
|
+
"before_compaction",
|
|
3496
|
+
(_event, ctx) => compactionStarted(ctx)
|
|
3497
|
+
);
|
|
3498
|
+
hookFn(
|
|
3499
|
+
"after_compaction",
|
|
3500
|
+
(_event, ctx) => compactionEnded(ctx)
|
|
3501
|
+
);
|
|
3502
|
+
hookFn(
|
|
3503
|
+
"message_received",
|
|
3504
|
+
(event) => noteMessageReceived(event)
|
|
3505
|
+
);
|
|
3110
3506
|
hookFn(
|
|
3111
3507
|
"agent_end",
|
|
3112
3508
|
async (event, ctx) => {
|
|
3509
|
+
const deadline = Date.now() + HOOK_BUDGET_MS - HOOK_MARGIN_MS;
|
|
3113
3510
|
const scope = buildScope2(resolveAgentId2(ctx));
|
|
3114
3511
|
const agentId = scope.agentId;
|
|
3115
3512
|
const storageCtx = scope.storageCtx;
|
|
3116
3513
|
logger.info(
|
|
3117
3514
|
`openclaw-amem: agent_end hook triggered (agent_id=${agentId}, success=${event.success}, messages=${event.messages?.length ?? 0})`
|
|
3118
3515
|
);
|
|
3516
|
+
const done = foregroundWorkStarted();
|
|
3517
|
+
let since;
|
|
3119
3518
|
try {
|
|
3120
3519
|
let shouldProcessTurn2 = function(text) {
|
|
3121
3520
|
if (text.trim().length < 10) return false;
|
|
@@ -3132,7 +3531,12 @@ ${text}${hookWarning}` }],
|
|
|
3132
3531
|
const msgs = event.messages || [];
|
|
3133
3532
|
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
|
|
3134
3533
|
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
|
|
3135
|
-
if (!lastUser || !lastAssistant)
|
|
3534
|
+
if (!lastUser || !lastAssistant) {
|
|
3535
|
+
logger.info(
|
|
3536
|
+
`openclaw-amem: agent_end skipped \u2014 no ${!lastUser ? "user" : "assistant"} message in ${msgs.length} messages`
|
|
3537
|
+
);
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3136
3540
|
const userText = typeof lastUser.content === "string" ? lastUser.content : lastUser.content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
|
|
3137
3541
|
const assistantText = typeof lastAssistant.content === "string" ? lastAssistant.content : lastAssistant.content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
|
|
3138
3542
|
if (!userText || !shouldProcessTurn2(userText)) return;
|
|
@@ -3146,12 +3550,18 @@ ${text}${hookWarning}` }],
|
|
|
3146
3550
|
const operations = await llmCrudDecision2(
|
|
3147
3551
|
userText,
|
|
3148
3552
|
assistantText,
|
|
3149
|
-
existingMemories.map((m) => ({ idx: m.idx, content: m.content }))
|
|
3553
|
+
existingMemories.map((m) => ({ idx: m.idx, content: m.content })),
|
|
3554
|
+
{ deadline }
|
|
3150
3555
|
);
|
|
3151
3556
|
if (!operations || operations.length === 0) return;
|
|
3152
|
-
|
|
3557
|
+
since = /* @__PURE__ */ new Date();
|
|
3558
|
+
for (const [i, op] of operations.entries()) {
|
|
3559
|
+
if (!hasTimeFor(deadline)) {
|
|
3560
|
+
logger.warn(`openclaw-amem: agent_end out of time, stored ${i} of ${operations.length} operations`);
|
|
3561
|
+
break;
|
|
3562
|
+
}
|
|
3153
3563
|
if (op.action === "NEW") {
|
|
3154
|
-
await addMemory(op.fact, agentId, { storageCtx });
|
|
3564
|
+
await addMemory(op.fact, agentId, { storageCtx, deadline });
|
|
3155
3565
|
logger.info(`openclaw-amem: CRUD NEW: "${op.fact.slice(0, 60)}${op.fact.length > 60 ? "..." : ""}"`);
|
|
3156
3566
|
} else if (op.action === "UPDATE" && op.existingIdx !== void 0) {
|
|
3157
3567
|
const target = existingMemories[op.existingIdx];
|
|
@@ -3160,7 +3570,7 @@ ${text}${hookWarning}` }],
|
|
|
3160
3570
|
const hash = (0, import_crypto2.createHash)("md5").update(op.fact).digest("hex");
|
|
3161
3571
|
const targetNote = await storageCtx.getNote(target.id, agentId);
|
|
3162
3572
|
if (!targetNote || !isPlausibleUpdateTarget(newEmbedding, targetNote.embedding, crudUpdateMinSim)) {
|
|
3163
|
-
await addMemory(op.fact, agentId, { storageCtx });
|
|
3573
|
+
await addMemory(op.fact, agentId, { storageCtx, deadline });
|
|
3164
3574
|
logger.warn(
|
|
3165
3575
|
`openclaw-amem: CRUD UPDATE on ${target.id.slice(0, 8)} looks mis-targeted \u2014 stored as a new memory instead`
|
|
3166
3576
|
);
|
|
@@ -3193,73 +3603,84 @@ ${text}${hookWarning}` }],
|
|
|
3193
3603
|
}
|
|
3194
3604
|
}
|
|
3195
3605
|
}
|
|
3196
|
-
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3197
|
-
logger.info(`openclaw-amem: starting mergeSimilarNotes for agent=${agentId}, date=${today}`);
|
|
3198
|
-
try {
|
|
3199
|
-
const merged = await mergeSimilarNotes(agentId, storageCtx);
|
|
3200
|
-
if (merged > 0) {
|
|
3201
|
-
logger.info(`openclaw-amem: merged ${merged} similar notes today (${today})`);
|
|
3202
|
-
} else {
|
|
3203
|
-
logger.info(`openclaw-amem: mergeSimilarNotes completed, 0 pairs merged (${today})`);
|
|
3204
|
-
}
|
|
3205
|
-
} catch (mergeErr) {
|
|
3206
|
-
logger.error(
|
|
3207
|
-
`openclaw-amem: mergeSimilarNotes FAILED \u2014 ${mergeErr.message}
|
|
3208
|
-
${mergeErr.stack}`
|
|
3209
|
-
);
|
|
3210
|
-
}
|
|
3211
3606
|
} catch (e) {
|
|
3212
3607
|
logger.warn(`openclaw-amem: agent_end CRUD hook failed \u2014 ${e.message}`);
|
|
3608
|
+
} finally {
|
|
3609
|
+
if (since) owe(resolveAgentId2(ctx), since);
|
|
3610
|
+
done();
|
|
3213
3611
|
}
|
|
3214
3612
|
},
|
|
3215
|
-
{ timeoutMs:
|
|
3613
|
+
{ timeoutMs: HOOK_BUDGET_MS }
|
|
3216
3614
|
);
|
|
3217
3615
|
logger.info("openclaw-amem: agent_end CRUD decision hook registered");
|
|
3218
3616
|
}
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
if (conflictSweepEnabled) {
|
|
3238
|
-
try {
|
|
3239
|
-
const res = await conflictSweep(defaultScope.agentId, {
|
|
3240
|
-
storageCtx: defaultScope.storageCtx,
|
|
3241
|
-
logger
|
|
3242
|
-
});
|
|
3243
|
-
if (res.pairsFound > 0) {
|
|
3244
|
-
logger.info(
|
|
3245
|
-
`openclaw-amem: Contradiction sweep flagged ${res.pairsFound} pair(s)` + (res.retired > 0 ? `, retired ${res.retired}` : "") + ` (${res.batchesScanned} batch(es) read, ${res.batchesSkipped} unchanged).`
|
|
3246
|
-
);
|
|
3617
|
+
scheduleNightly(
|
|
3618
|
+
() => runNightly({
|
|
3619
|
+
owedFile,
|
|
3620
|
+
defaultAgent: resolveAgentId2(),
|
|
3621
|
+
mergeDay: async (rawAgentId, day) => {
|
|
3622
|
+
const scope = buildScope2(rawAgentId);
|
|
3623
|
+
const merged = await mergeSimilarNotes(scope.agentId, scope.storageCtx, day);
|
|
3624
|
+
if (merged > 0) logger.info(`openclaw-amem: merged ${merged} similar notes (${scope.agentId}, ${day})`);
|
|
3625
|
+
},
|
|
3626
|
+
after: [
|
|
3627
|
+
{
|
|
3628
|
+
name: "consolidation",
|
|
3629
|
+
run: async () => {
|
|
3630
|
+
logger.info("openclaw-amem: Running scheduled daily consolidation...");
|
|
3631
|
+
const merged = await consolidateMemories(defaultScope.agentId, logger, defaultScope.storageCtx);
|
|
3632
|
+
if (merged > 0) {
|
|
3633
|
+
logger.info(`openclaw-amem: Scheduled daily consolidation merged ${merged} pairs.`);
|
|
3634
|
+
}
|
|
3247
3635
|
}
|
|
3248
|
-
}
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3636
|
+
},
|
|
3637
|
+
// Story 43: the cold half of the tiering split. The per-turn CRUD decision
|
|
3638
|
+
// runs on the fast model, which is safe but misses contradictions; this is
|
|
3639
|
+
// what catches them. Runs AFTER consolidation, as its own step, so neither
|
|
3640
|
+
// task can take the other down.
|
|
3641
|
+
//
|
|
3642
|
+
// Only batches that gained a note since the last run are re-read, so a
|
|
3643
|
+
// steady-state night costs a call or two rather than a full re-read.
|
|
3644
|
+
...conflictSweepEnabled ? [
|
|
3645
|
+
{
|
|
3646
|
+
name: "contradiction sweep",
|
|
3647
|
+
run: async () => {
|
|
3648
|
+
const res = await conflictSweep(defaultScope.agentId, {
|
|
3649
|
+
storageCtx: defaultScope.storageCtx,
|
|
3650
|
+
logger
|
|
3651
|
+
});
|
|
3652
|
+
if (res.pairsFound > 0) {
|
|
3653
|
+
logger.info(
|
|
3654
|
+
`openclaw-amem: Contradiction sweep flagged ${res.pairsFound} pair(s)` + (res.retired > 0 ? `, retired ${res.retired}` : "") + ` (${res.batchesScanned} batch(es) read, ${res.batchesSkipped} unchanged).`
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
] : []
|
|
3660
|
+
],
|
|
3661
|
+
inBackground: (work) => runInBackground(busySinceNow(), work),
|
|
3662
|
+
preempted: (err) => err instanceof BackgroundPreempted,
|
|
3663
|
+
logger
|
|
3664
|
+
}),
|
|
3665
|
+
(err) => logger.warn(`openclaw-amem: nightly job failed \u2014 ${err.message}`)
|
|
3666
|
+
);
|
|
3256
3667
|
if (typeof api.registerService === "function") {
|
|
3257
3668
|
api.registerService({
|
|
3258
3669
|
id: "amem-plugin",
|
|
3259
3670
|
start() {
|
|
3671
|
+
const events = api.runtime?.events;
|
|
3672
|
+
if (typeof events?.onAgentEvent === "function") {
|
|
3673
|
+
watchAgentEvents((listener) => events.onAgentEvent(listener));
|
|
3674
|
+
} else {
|
|
3675
|
+
logger.warn(
|
|
3676
|
+
"openclaw-amem: this OpenClaw does not report runs to plugins, so the nightly job cannot wait for them"
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3260
3679
|
logger.info(`openclaw-amem: started (backend: amem-qdrant, default agentId: ${defaultScope.agentId})`);
|
|
3261
3680
|
},
|
|
3262
3681
|
stop() {
|
|
3682
|
+
cancelNightly();
|
|
3683
|
+
unwatchAgentEvents();
|
|
3263
3684
|
logger.info("openclaw-amem: stopped");
|
|
3264
3685
|
}
|
|
3265
3686
|
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "amem",
|
|
4
4
|
"description": "Catches memories that contradict each other. Notes rewrite themselves as new ones arrive, link into a graph, and stay separated per agent and per person. Runs on a small model. Requires a local Qdrant. 中文走 jieba 分词,提示词有中文版。",
|
|
5
5
|
"icon": "https://amem.owo.lc/logo.png",
|
|
6
|
-
"version": "2.1.
|
|
6
|
+
"version": "2.1.3",
|
|
7
7
|
"kind": "memory",
|
|
8
8
|
"contracts": {
|
|
9
9
|
"tools": [
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"AMEM_LLM_STRONG_MODEL",
|
|
29
29
|
"AMEM_LLM_STRONG_BASE_URL",
|
|
30
30
|
"AMEM_LLM_CRUD_ROLE",
|
|
31
|
+
"AMEM_LLM_THINKING",
|
|
31
32
|
"AMEM_CONFLICT_MODE",
|
|
32
33
|
"AMEM_LLM_TIMEOUT",
|
|
33
34
|
"AMEM_CRUD_UPDATE_MIN_SIM",
|
|
@@ -138,6 +139,15 @@
|
|
|
138
139
|
"default": "fast",
|
|
139
140
|
"description": "Which tier the agent_end CRUD decision runs on. Defaults to fast: it runs every turn, and its destructive failure mode is handled by the update guard rather than by model tier. Overridden by AMEM_LLM_CRUD_ROLE."
|
|
140
141
|
},
|
|
142
|
+
"llmThinking": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"enum": [
|
|
145
|
+
"off",
|
|
146
|
+
"auto"
|
|
147
|
+
],
|
|
148
|
+
"default": "off",
|
|
149
|
+
"description": "Whether fast-tier calls on the Anthropic API turn thinking off. off sends thinking disabled; a model that refuses it, such as Opus 5.5 or Fable, keeps thinking. auto sends no thinking field. Overridden by AMEM_LLM_THINKING."
|
|
150
|
+
},
|
|
141
151
|
"conflictSweep": {
|
|
142
152
|
"type": "boolean",
|
|
143
153
|
"default": true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-amem",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"description": "Catches memories that contradict each other. Notes rewrite themselves as new ones arrive, link into a graph, and stay separated per agent and per person. Runs on a small model. Requires a local Qdrant. 中文走 jieba 分词,提示词有中文版。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@anthropic-ai/sdk": "^0.
|
|
19
|
+
"@anthropic-ai/sdk": "^0.126.0",
|
|
20
20
|
"@huggingface/transformers": "^4.2.0",
|
|
21
21
|
"@node-rs/jieba": "^2.0.1",
|
|
22
22
|
"@qdrant/js-client-rest": "^1.18.0",
|