@alfe.ai/openclaw-memory-cloud 0.0.37 → 0.0.39
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 +37 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -60
- package/dist/index.js +2 -396
- package/dist/plugin.cjs +2 -0
- package/dist/plugin.d.cts +59 -0
- package/dist/plugin.d.cts.map +1 -0
- package/dist/plugin.d.ts +59 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +2 -0
- package/dist/plugin2.cjs +1107 -0
- package/dist/plugin2.d.cts +2 -0
- package/dist/plugin2.d.ts +2 -0
- package/dist/plugin2.js +1104 -0
- package/dist/plugin2.js.map +1 -0
- package/openclaw.plugin.json +6 -5
- package/package.json +27 -7
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -309
- package/dist/auto-capture.d.ts +0 -45
- package/dist/auto-capture.d.ts.map +0 -1
- package/dist/auto-capture.js +0 -101
- package/dist/auto-capture.js.map +0 -1
- package/dist/auto-recall.d.ts +0 -22
- package/dist/auto-recall.d.ts.map +0 -1
- package/dist/auto-recall.js +0 -63
- package/dist/auto-recall.js.map +0 -1
- package/dist/formatter.d.ts +0 -7
- package/dist/formatter.d.ts.map +0 -1
- package/dist/formatter.js +0 -27
- package/dist/formatter.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/ingest-epoch.d.ts +0 -38
- package/dist/ingest-epoch.d.ts.map +0 -1
- package/dist/ingest-epoch.js +0 -66
- package/dist/ingest-epoch.js.map +0 -1
- package/dist/session-backfill.d.ts +0 -54
- package/dist/session-backfill.d.ts.map +0 -1
- package/dist/session-backfill.js +0 -192
- package/dist/session-backfill.js.map +0 -1
- package/dist/types.d.ts +0 -113
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/src/__tests__/auto-capture.test.ts +0 -115
- package/src/__tests__/ingest-epoch.test.ts +0 -67
- package/src/__tests__/session-backfill.test.ts +0 -289
- package/src/auto-capture.ts +0 -108
- package/src/auto-recall.ts +0 -66
- package/src/formatter.ts +0 -30
- package/src/index.ts +0 -464
- package/src/ingest-epoch.ts +0 -78
- package/src/session-backfill.ts +0 -220
- package/src/types.ts +0 -93
- package/sst-env.d.ts +0 -10
- package/tsconfig.json +0 -20
package/src/index.ts
DELETED
|
@@ -1,464 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* memory-cloud — OpenClaw memory extension
|
|
3
|
-
*
|
|
4
|
-
* Replaces the builtin LanceDB memory extension with cloud-backed storage:
|
|
5
|
-
* - Turbopuffer for vector storage (via services/memory Lambda proxy)
|
|
6
|
-
* - DynamoDB for knowledge graph
|
|
7
|
-
* - Haiku for classification + entity extraction
|
|
8
|
-
*
|
|
9
|
-
* Registers:
|
|
10
|
-
* - Memory tools: memory_recall, memory_store, memory_forget, memory_navigate, memory_graph, memory_stats
|
|
11
|
-
* - Lifecycle hooks: before_agent_start (auto-recall), agent_end (auto-capture), message_received (idle debounce)
|
|
12
|
-
* - Memory runtime: replaces builtin SQLite backend with cloud backend
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import * as fs from "node:fs/promises";
|
|
16
|
-
import * as path from "node:path";
|
|
17
|
-
import { resolveConfig } from "@alfe.ai/config";
|
|
18
|
-
import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
|
|
19
|
-
import { AutoCapture } from "./auto-capture.js";
|
|
20
|
-
import { AutoRecall } from "./auto-recall.js";
|
|
21
|
-
import { formatSearchResults } from "./formatter.js";
|
|
22
|
-
import { runSessionsBackfill } from "./session-backfill.js";
|
|
23
|
-
import { resolveIngestEpoch } from "./ingest-epoch.js";
|
|
24
|
-
import type { MemoryCloudConfig } from "./types.js";
|
|
25
|
-
import { createRequire } from 'node:module';
|
|
26
|
-
const require = createRequire(import.meta.url);
|
|
27
|
-
const pkg = require('../package.json') as { version: string };
|
|
28
|
-
|
|
29
|
-
// OpenClaw plugin types — these match the OpenClaw plugin API contract
|
|
30
|
-
interface PluginApi {
|
|
31
|
-
pluginConfig?: Record<string, unknown>;
|
|
32
|
-
config: Record<string, unknown>;
|
|
33
|
-
logger: {
|
|
34
|
-
info: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
35
|
-
debug: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
36
|
-
warn: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
37
|
-
error: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
38
|
-
};
|
|
39
|
-
registerTool: (factory: (ctx: ToolContext) => Tool, opts?: { names?: string[] }) => void;
|
|
40
|
-
registerHook: (events: string | string[], handler: (...args: unknown[]) => unknown, opts?: { name?: string; description?: string }) => void;
|
|
41
|
-
on: (hookName: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }) => void;
|
|
42
|
-
registerMemoryPromptSection: (builder: (params: { availableTools: Set<string> }) => string[]) => void;
|
|
43
|
-
registerMemoryRuntime: (runtime: unknown) => void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface ToolContext {
|
|
47
|
-
agentId?: string;
|
|
48
|
-
sessionKey?: string;
|
|
49
|
-
sessionId?: string;
|
|
50
|
-
messageChannel?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface Tool {
|
|
54
|
-
name: string;
|
|
55
|
-
label: string;
|
|
56
|
-
description: string;
|
|
57
|
-
parameters: Record<string, unknown>;
|
|
58
|
-
execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const DEFAULT_CONFIG: MemoryCloudConfig = {
|
|
64
|
-
autoCapture: true,
|
|
65
|
-
autoRecall: true,
|
|
66
|
-
captureMaxChars: 500,
|
|
67
|
-
idleFlushSeconds: 60,
|
|
68
|
-
backfillSessions: true,
|
|
69
|
-
backfillMaxSessions: 50,
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
function resolvePluginConfig(pluginConfig?: Record<string, unknown>): MemoryCloudConfig {
|
|
73
|
-
return {
|
|
74
|
-
autoCapture: typeof pluginConfig?.autoCapture === "boolean" ? pluginConfig.autoCapture : DEFAULT_CONFIG.autoCapture,
|
|
75
|
-
autoRecall: typeof pluginConfig?.autoRecall === "boolean" ? pluginConfig.autoRecall : DEFAULT_CONFIG.autoRecall,
|
|
76
|
-
captureMaxChars: typeof pluginConfig?.captureMaxChars === "number" ? pluginConfig.captureMaxChars : DEFAULT_CONFIG.captureMaxChars,
|
|
77
|
-
idleFlushSeconds: typeof pluginConfig?.idleFlushSeconds === "number" ? pluginConfig.idleFlushSeconds : DEFAULT_CONFIG.idleFlushSeconds,
|
|
78
|
-
backfillSessions: typeof pluginConfig?.backfillSessions === "boolean" ? pluginConfig.backfillSessions : DEFAULT_CONFIG.backfillSessions,
|
|
79
|
-
backfillMaxSessions:
|
|
80
|
-
typeof pluginConfig?.backfillMaxSessions === "number" && Number.isFinite(pluginConfig.backfillMaxSessions) && pluginConfig.backfillMaxSessions > 0
|
|
81
|
-
? Math.floor(pluginConfig.backfillMaxSessions)
|
|
82
|
-
: DEFAULT_CONFIG.backfillMaxSessions,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export default {
|
|
87
|
-
id: "@alfe.ai/openclaw-memory-cloud",
|
|
88
|
-
name: "Cloud Memory",
|
|
89
|
-
description: "Persistent agent memory backed by Turbopuffer + DynamoDB knowledge graph",
|
|
90
|
-
version: pkg.version,
|
|
91
|
-
kind: "memory" as const,
|
|
92
|
-
|
|
93
|
-
register(api: PluginApi): void {
|
|
94
|
-
// First thing, before any registerTool call: tool failures emit a
|
|
95
|
-
// deterministic [ERROR] line the gateway's runtime-output monitor captures
|
|
96
|
-
// to Sentry. See @alfe.ai/agent-api-client tool-error-capture.
|
|
97
|
-
installToolErrorCapture(api, { plugin: "openclaw-memory-cloud" });
|
|
98
|
-
const config = resolvePluginConfig(api.pluginConfig);
|
|
99
|
-
const logger = api.logger;
|
|
100
|
-
|
|
101
|
-
const alfeConfig = resolveConfig();
|
|
102
|
-
const client = new AgentApiClient({
|
|
103
|
-
apiKey: alfeConfig.apiKey,
|
|
104
|
-
apiUrl: alfeConfig.apiUrl,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// Strictly-monotonic per-boot epoch, resolved once at process start.
|
|
108
|
-
// Guards against a backward wall-clock step across a restart (which would
|
|
109
|
-
// otherwise trap every live-capture flush in the server's stale-boot
|
|
110
|
-
// branch and silently drop it). See ingest-epoch.ts.
|
|
111
|
-
const ingestEpoch = resolveIngestEpoch({ logger });
|
|
112
|
-
const autoCapture = new AutoCapture(client, config, logger, ingestEpoch);
|
|
113
|
-
const autoRecall = new AutoRecall(client, config, logger);
|
|
114
|
-
|
|
115
|
-
// ─── Memory prompt section ──────────────────────────────────
|
|
116
|
-
api.registerMemoryPromptSection(({ availableTools }) => {
|
|
117
|
-
const lines: string[] = ["## Memory"];
|
|
118
|
-
if (availableTools.has("memory_recall")) {
|
|
119
|
-
lines.push("Use memory_recall to search your conversation history and knowledge graph.");
|
|
120
|
-
lines.push("Results include structured facts (from knowledge graph) and relevant conversation excerpts.");
|
|
121
|
-
}
|
|
122
|
-
if (availableTools.has("memory_store")) {
|
|
123
|
-
lines.push("Use memory_store to explicitly save important information for future reference.");
|
|
124
|
-
}
|
|
125
|
-
if (availableTools.has("memory_learn")) {
|
|
126
|
-
lines.push("Use memory_learn when the user asks you to learn or remember something — or when you read a doc with durable facts. Pass the source text directly; the system auto-classifies and extracts entities.");
|
|
127
|
-
}
|
|
128
|
-
if (availableTools.has("memory_graph")) {
|
|
129
|
-
lines.push("Use memory_graph to look up what you know about a specific entity.");
|
|
130
|
-
}
|
|
131
|
-
return lines;
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// ─── Tools ──────────────────────────────────────────────────
|
|
135
|
-
api.registerTool(() => ({
|
|
136
|
-
name: "memory_recall",
|
|
137
|
-
label: "Memory Recall",
|
|
138
|
-
description: "Search conversation memory and knowledge graph. Returns structured facts and relevant conversation excerpts.",
|
|
139
|
-
parameters: {
|
|
140
|
-
type: "object",
|
|
141
|
-
properties: {
|
|
142
|
-
query: { type: "string", description: "What to search for" },
|
|
143
|
-
limit: { type: "number", description: "Maximum results (default 10)" },
|
|
144
|
-
topic: { type: "string", description: "Filter by topic" },
|
|
145
|
-
tag: { type: "string", description: "Filter by tag (fact/decision/preference/event/discovery)" },
|
|
146
|
-
},
|
|
147
|
-
required: ["query"],
|
|
148
|
-
},
|
|
149
|
-
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
150
|
-
const results = await client.memorySearch(
|
|
151
|
-
params.query as string,
|
|
152
|
-
{
|
|
153
|
-
limit: params.limit as number | undefined,
|
|
154
|
-
topic: params.topic as string | undefined,
|
|
155
|
-
tag: params.tag as string | undefined,
|
|
156
|
-
},
|
|
157
|
-
);
|
|
158
|
-
return formatSearchResults(results);
|
|
159
|
-
},
|
|
160
|
-
}), { names: ["memory_recall", "memory_search"] });
|
|
161
|
-
|
|
162
|
-
api.registerTool(() => ({
|
|
163
|
-
name: "memory_store",
|
|
164
|
-
label: "Memory Store",
|
|
165
|
-
description: "Explicitly save a piece of information to long-term memory.",
|
|
166
|
-
parameters: {
|
|
167
|
-
type: "object",
|
|
168
|
-
properties: {
|
|
169
|
-
text: { type: "string", description: "The information to remember" },
|
|
170
|
-
topic: { type: "string", description: "Topic category (e.g., person name, project)" },
|
|
171
|
-
tag: { type: "string", description: "Memory type: fact, decision, preference, event, discovery" },
|
|
172
|
-
importance: { type: "number", description: "Importance 0-1 (default 0.7)" },
|
|
173
|
-
},
|
|
174
|
-
required: ["text"],
|
|
175
|
-
},
|
|
176
|
-
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
177
|
-
const result = await client.memoryStore(
|
|
178
|
-
params.text as string,
|
|
179
|
-
{
|
|
180
|
-
topic: params.topic as string | undefined,
|
|
181
|
-
tag: params.tag as string | undefined,
|
|
182
|
-
importance: params.importance as number | undefined,
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
return `Stored memory: ${result.memoryId}`;
|
|
186
|
-
},
|
|
187
|
-
}), { names: ["memory_store"] });
|
|
188
|
-
|
|
189
|
-
api.registerTool(() => ({
|
|
190
|
-
name: "memory_learn",
|
|
191
|
-
label: "Memory Learn",
|
|
192
|
-
description: "Save arbitrary content (a paragraph, a doc, a fact list) into long-term memory. The system auto-classifies and extracts entities. Use this when the user asks you to learn, remember, or study something — or when you read a document worth retaining. Pass the source text directly; do not paraphrase first.",
|
|
193
|
-
parameters: {
|
|
194
|
-
type: "object",
|
|
195
|
-
properties: {
|
|
196
|
-
content: { type: "string", description: "Inline text to ingest. Provide either content OR path, not both." },
|
|
197
|
-
path: { type: "string", description: "Workspace-relative file path to read and ingest. Provide either content OR path, not both." },
|
|
198
|
-
source: { type: "string", description: "Optional label describing where the content came from (e.g. doc title, url)." },
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
202
|
-
const content = typeof params.content === "string" ? params.content : undefined;
|
|
203
|
-
const filePath = typeof params.path === "string" ? params.path : undefined;
|
|
204
|
-
const source = typeof params.source === "string" ? params.source : undefined;
|
|
205
|
-
|
|
206
|
-
if (!content && !filePath) {
|
|
207
|
-
return "Error: provide either `content` or `path`.";
|
|
208
|
-
}
|
|
209
|
-
if (content && filePath) {
|
|
210
|
-
return "Error: provide either `content` or `path`, not both.";
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
let text: string;
|
|
214
|
-
let resolvedSource = source;
|
|
215
|
-
let sourceType: "file" | "inline" = "inline";
|
|
216
|
-
|
|
217
|
-
if (filePath !== undefined) {
|
|
218
|
-
const cwd = process.cwd();
|
|
219
|
-
const resolved = path.resolve(cwd, filePath);
|
|
220
|
-
if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
|
|
221
|
-
return `Error: path "${filePath}" escapes the workspace.`;
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
text = await fs.readFile(resolved, "utf8");
|
|
225
|
-
} catch (err) {
|
|
226
|
-
return `Error reading "${filePath}": ${err instanceof Error ? err.message : String(err)}`;
|
|
227
|
-
}
|
|
228
|
-
resolvedSource = source ?? filePath;
|
|
229
|
-
sourceType = "file";
|
|
230
|
-
} else if (content !== undefined) {
|
|
231
|
-
text = content;
|
|
232
|
-
} else {
|
|
233
|
-
return "Error: provide either `content` or `path`.";
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
const result = await client.memoryLearn({ text, source: resolvedSource, sourceType });
|
|
238
|
-
const label = result.source ?? "content";
|
|
239
|
-
return `Stored ${String(result.memoriesStored)} memories (${String(result.triplesStored)} facts, ${String(result.chunks)} chunks) from ${label}.`;
|
|
240
|
-
} catch (err) {
|
|
241
|
-
logger.warn("memory_learn failed", { err: String(err) });
|
|
242
|
-
return `Error: failed to store memory — ${err instanceof Error ? err.message : String(err)}`;
|
|
243
|
-
}
|
|
244
|
-
},
|
|
245
|
-
}), { names: ["memory_learn"] });
|
|
246
|
-
|
|
247
|
-
api.registerTool(() => ({
|
|
248
|
-
name: "memory_forget",
|
|
249
|
-
label: "Memory Forget",
|
|
250
|
-
description: "Search for and delete memories matching a query.",
|
|
251
|
-
parameters: {
|
|
252
|
-
type: "object",
|
|
253
|
-
properties: {
|
|
254
|
-
query: { type: "string", description: "Search for memories to delete" },
|
|
255
|
-
},
|
|
256
|
-
required: ["query"],
|
|
257
|
-
},
|
|
258
|
-
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
259
|
-
const results = await client.memorySearch(params.query as string, { limit: 5 });
|
|
260
|
-
if (results.memories.length === 0) return "No matching memories found.";
|
|
261
|
-
|
|
262
|
-
const deleted: string[] = [];
|
|
263
|
-
for (const mem of results.memories) {
|
|
264
|
-
try {
|
|
265
|
-
await client.memoryDelete(mem.id);
|
|
266
|
-
deleted.push(mem.id);
|
|
267
|
-
} catch {
|
|
268
|
-
logger.warn("Failed to delete memory", { memoryId: mem.id });
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
return `Deleted ${String(deleted.length)} of ${String(results.memories.length)} matching memories.`;
|
|
272
|
-
},
|
|
273
|
-
}), { names: ["memory_forget"] });
|
|
274
|
-
|
|
275
|
-
api.registerTool(() => ({
|
|
276
|
-
name: "memory_graph",
|
|
277
|
-
label: "Knowledge Graph",
|
|
278
|
-
description: "Look up what you know about a specific entity from the knowledge graph.",
|
|
279
|
-
parameters: {
|
|
280
|
-
type: "object",
|
|
281
|
-
properties: {
|
|
282
|
-
entity: { type: "string", description: "The entity to look up (person, concept, system)" },
|
|
283
|
-
},
|
|
284
|
-
required: ["entity"],
|
|
285
|
-
},
|
|
286
|
-
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
287
|
-
const result = await client.memoryLookupEntity(params.entity as string);
|
|
288
|
-
if (result.triples.length === 0) return `No knowledge found about "${params.entity as string}".`;
|
|
289
|
-
const facts = result.triples.map((t) => `- ${result.subject} ${t.predicate} ${t.object} (since ${t.validFrom.slice(0, 10)})`);
|
|
290
|
-
return `Known facts about ${result.subject}:\n${facts.join("\n")}`;
|
|
291
|
-
},
|
|
292
|
-
}), { names: ["memory_graph"] });
|
|
293
|
-
|
|
294
|
-
api.registerTool(() => ({
|
|
295
|
-
name: "memory_stats",
|
|
296
|
-
label: "Memory Stats",
|
|
297
|
-
description: "Get memory storage statistics.",
|
|
298
|
-
parameters: { type: "object", properties: {} },
|
|
299
|
-
execute: async () => {
|
|
300
|
-
const s = await client.memoryStats();
|
|
301
|
-
return `Memories: ${String(s.vectorCount)}, Knowledge facts: ${String(s.tripleCount)}, Storage: ${String(Math.round(s.storageEstimateBytes / 1024))} KB`;
|
|
302
|
-
},
|
|
303
|
-
}), { names: ["memory_stats"] });
|
|
304
|
-
|
|
305
|
-
api.registerTool(() => ({
|
|
306
|
-
name: "memory_navigate",
|
|
307
|
-
label: "Memory Navigate",
|
|
308
|
-
description: "Browse your memory palace structure — list topics, subtopics, and memory counts.",
|
|
309
|
-
parameters: { type: "object", properties: {} },
|
|
310
|
-
execute: async () => {
|
|
311
|
-
const nav = await client.memoryNavigate();
|
|
312
|
-
const topics = nav.topics;
|
|
313
|
-
if (topics.length === 0) return "No memories stored yet.";
|
|
314
|
-
const lines = topics.map((t) => `- ${t.name} (${String(t.tripleCount)} facts, subtopics: ${t.subtopics.join(", ") || "none"})`);
|
|
315
|
-
return `Memory topics:\n${lines.join("\n")}`;
|
|
316
|
-
},
|
|
317
|
-
}), { names: ["memory_navigate"] });
|
|
318
|
-
|
|
319
|
-
// ─── Lifecycle hooks ────────────────────────────────────────
|
|
320
|
-
|
|
321
|
-
// Auto-recall: inject relevant memories at session start
|
|
322
|
-
api.on("before_agent_start", async (event: unknown, ctx: unknown) => {
|
|
323
|
-
const startEvent = event as Record<string, unknown>;
|
|
324
|
-
const agentCtx = ctx as Record<string, unknown>;
|
|
325
|
-
const prompt = typeof startEvent.prompt === "string" ? startEvent.prompt : "";
|
|
326
|
-
const sessionKey = typeof agentCtx.sessionKey === "string" ? agentCtx.sessionKey : undefined;
|
|
327
|
-
const channelId = typeof agentCtx.channelId === "string" ? agentCtx.channelId : undefined;
|
|
328
|
-
|
|
329
|
-
if (sessionKey) {
|
|
330
|
-
autoCapture.setSession(sessionKey, { channelId });
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const contextXml = await autoRecall.loadForPrompt(prompt);
|
|
334
|
-
if (contextXml) {
|
|
335
|
-
return { prependContext: contextXml };
|
|
336
|
-
}
|
|
337
|
-
return undefined;
|
|
338
|
-
}, { priority: 10 });
|
|
339
|
-
|
|
340
|
-
// Track incoming messages for idle debounce
|
|
341
|
-
api.on("message_received", (event: unknown) => {
|
|
342
|
-
const msgEvent = event as Record<string, unknown>;
|
|
343
|
-
const content = typeof msgEvent.content === "string" ? msgEvent.content : "";
|
|
344
|
-
autoCapture.trackMessage("user", content);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// Track outgoing messages
|
|
348
|
-
api.on("message_sending", (event: unknown) => {
|
|
349
|
-
const msgEvent = event as Record<string, unknown>;
|
|
350
|
-
const content = typeof msgEvent.content === "string" ? msgEvent.content : "";
|
|
351
|
-
autoCapture.trackMessage("assistant", content);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// Final flush on session end
|
|
355
|
-
api.on("agent_end", async (event: unknown) => {
|
|
356
|
-
const endEvent = event as Record<string, unknown>;
|
|
357
|
-
if (endEvent.success !== false) {
|
|
358
|
-
await autoCapture.onAgentEnd();
|
|
359
|
-
}
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
// Fire-and-forget first-run sync: workspace files (MEMORY.md / memory/*.md)
|
|
363
|
-
// and pre-existing chat sessions, each idempotent via its own
|
|
364
|
-
// PalaceMetadata flag (bootstrapSyncedAt / sessionsBackfillSyncedAt).
|
|
365
|
-
void runStartupSync(client, config, logger).catch((err: unknown) => {
|
|
366
|
-
logger.warn("memory startup sync failed; will retry on next startup", { err: String(err) });
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
logger.info("memory-cloud extension registered", { autoCapture: config.autoCapture, autoRecall: config.autoRecall });
|
|
370
|
-
},
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
async function runStartupSync(
|
|
374
|
-
client: AgentApiClient,
|
|
375
|
-
config: MemoryCloudConfig,
|
|
376
|
-
logger: PluginApi["logger"],
|
|
377
|
-
): Promise<void> {
|
|
378
|
-
// Fetch status ONCE, before the file bootstrap runs: the sessions backfill
|
|
379
|
-
// computes its dedup cutoff from the pre-bootstrap `syncedAt` (an agent
|
|
380
|
-
// that already had memory installed captured everything after that moment
|
|
381
|
-
// live, so backfilling past it would duplicate memories).
|
|
382
|
-
let status: { synced: boolean; syncedAt?: string; sessionsBackfillSynced?: boolean };
|
|
383
|
-
try {
|
|
384
|
-
status = await client.memoryBootstrapStatus();
|
|
385
|
-
} catch (err) {
|
|
386
|
-
logger.debug("memory bootstrap status check failed; skipping", { err: String(err) });
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const startupMs = Date.now();
|
|
390
|
-
|
|
391
|
-
if (status.synced) {
|
|
392
|
-
logger.debug("memory bootstrap: already synced, skipping");
|
|
393
|
-
} else {
|
|
394
|
-
try {
|
|
395
|
-
await runBootstrap(client, logger);
|
|
396
|
-
} catch (err) {
|
|
397
|
-
logger.warn("memory bootstrap failed; will retry on next startup", { err: String(err) });
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Strict `=== false` on purpose: an old server (deployed before the
|
|
402
|
-
// sessions-backfill release) omits the field entirely, and its mark
|
|
403
|
-
// endpoint would clobber `bootstrapSyncedAt` — corrupting the cutoff
|
|
404
|
-
// anchor. Defer until the service is deployed and reports the flag.
|
|
405
|
-
if (config.backfillSessions && status.sessionsBackfillSynced === false) {
|
|
406
|
-
const installedAtMs = status.syncedAt ? Date.parse(status.syncedAt) : Number.POSITIVE_INFINITY;
|
|
407
|
-
const cutoffMs = Math.min(startupMs, Number.isNaN(installedAtMs) ? Number.POSITIVE_INFINITY : installedAtMs);
|
|
408
|
-
await runSessionsBackfill(client, config, logger, { cutoffMs });
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
async function runBootstrap(
|
|
413
|
-
client: AgentApiClient,
|
|
414
|
-
logger: PluginApi["logger"],
|
|
415
|
-
): Promise<void> {
|
|
416
|
-
const cwd = process.cwd();
|
|
417
|
-
const filesToSync: string[] = [];
|
|
418
|
-
|
|
419
|
-
try {
|
|
420
|
-
await fs.access(path.join(cwd, "MEMORY.md"));
|
|
421
|
-
filesToSync.push("MEMORY.md");
|
|
422
|
-
} catch { /* missing — fine */ }
|
|
423
|
-
|
|
424
|
-
try {
|
|
425
|
-
const memoryDir = path.join(cwd, "memory");
|
|
426
|
-
const entries = await fs.readdir(memoryDir, { withFileTypes: true });
|
|
427
|
-
for (const entry of entries) {
|
|
428
|
-
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
429
|
-
filesToSync.push(path.join("memory", entry.name));
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
} catch { /* no memory/ dir — fine */ }
|
|
433
|
-
|
|
434
|
-
if (filesToSync.length === 0) {
|
|
435
|
-
logger.debug("memory bootstrap: no files to sync, marking complete");
|
|
436
|
-
try { await client.memoryBootstrapStatusMark(); } catch { /* retry next start */ }
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
logger.info("memory bootstrap: ingesting files", { count: filesToSync.length });
|
|
441
|
-
|
|
442
|
-
for (const relPath of filesToSync) {
|
|
443
|
-
try {
|
|
444
|
-
const text = await fs.readFile(path.join(cwd, relPath), "utf8");
|
|
445
|
-
if (text.trim().length === 0) continue;
|
|
446
|
-
const result = await client.memoryLearn({ text, source: relPath, sourceType: "file" });
|
|
447
|
-
logger.debug("memory bootstrap: file ingested", {
|
|
448
|
-
file: relPath,
|
|
449
|
-
memoriesStored: result.memoriesStored,
|
|
450
|
-
triplesStored: result.triplesStored,
|
|
451
|
-
});
|
|
452
|
-
} catch (err) {
|
|
453
|
-
logger.warn("memory bootstrap: file failed; will retry on next startup", { file: relPath, err: String(err) });
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
try {
|
|
459
|
-
await client.memoryBootstrapStatusMark();
|
|
460
|
-
logger.info("memory bootstrap: complete");
|
|
461
|
-
} catch (err) {
|
|
462
|
-
logger.warn("memory bootstrap: mark failed; will retry on next startup", { err: String(err) });
|
|
463
|
-
}
|
|
464
|
-
}
|
package/src/ingest-epoch.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Strictly-monotonic per-boot ingest epoch.
|
|
3
|
-
*
|
|
4
|
-
* Live `AutoCapture` stamps each flush with a boot epoch so the memory service
|
|
5
|
-
* can tell a restarted daemon (whose message-index counter has reset to 0) from
|
|
6
|
-
* a continuing one and reset the per-session high-water mark accordingly (see
|
|
7
|
-
* services/memory/src/lib/ingest-offset.ts).
|
|
8
|
-
*
|
|
9
|
-
* `Date.now()` alone is NOT safe as that epoch: if the wall clock steps BACKWARD
|
|
10
|
-
* across a restart (NTP correction, or a boot before NTP sync), the new
|
|
11
|
-
* process's epoch would be LESS than the epoch already stored on the server —
|
|
12
|
-
* and the server's `incomingEpoch < storedEpoch` branch classifies every such
|
|
13
|
-
* flush as a stale-boot late delivery and acks it WITHOUT storing. That would
|
|
14
|
-
* silently drop ALL live captures for the entire life of that process (worse
|
|
15
|
-
* than the pre-fix bug, which self-healed once the counter climbed).
|
|
16
|
-
*
|
|
17
|
-
* The guard: persist the last epoch to a single small file and take
|
|
18
|
-
* `max(Date.now(), lastEpoch + 1)`. This is strictly increasing regardless of
|
|
19
|
-
* clock direction, needs no server round-trip, and is written exactly once per
|
|
20
|
-
* boot (a single integer — NOT the per-message counter that made a local state
|
|
21
|
-
* file unattractive for the message index itself).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
-
import { dirname, join } from "node:path";
|
|
26
|
-
import { homedir } from "node:os";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* `~/.alfe` is the plugin's canonical local-state dir (config lives at
|
|
30
|
-
* `~/.alfe/config.toml`, chat sessions at `~/.alfe/sessions/chat`), and
|
|
31
|
-
* `alfe remove` wipes it — so the epoch never outlives the agent it belongs to.
|
|
32
|
-
*/
|
|
33
|
-
const DEFAULT_EPOCH_FILE = join(homedir(), ".alfe", "memory", "ingest-epoch.json");
|
|
34
|
-
|
|
35
|
-
export interface ResolveIngestEpochOptions {
|
|
36
|
-
/** Override the state-file path (tests). */
|
|
37
|
-
epochFile?: string;
|
|
38
|
-
/** Override the clock (tests). */
|
|
39
|
-
now?: number;
|
|
40
|
-
logger?: { warn: (msg: string, ctx?: Record<string, unknown>) => void };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function readLastEpoch(epochFile: string): number {
|
|
44
|
-
try {
|
|
45
|
-
const parsed: unknown = JSON.parse(readFileSync(epochFile, "utf8"));
|
|
46
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
47
|
-
const epoch = (parsed as Record<string, unknown>).epoch;
|
|
48
|
-
if (typeof epoch === "number" && Number.isFinite(epoch)) {
|
|
49
|
-
return epoch;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
} catch {
|
|
53
|
-
// Missing or corrupt file → treat as no prior epoch.
|
|
54
|
-
}
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Resolve and persist a strictly-monotonic epoch for this process. Best-effort:
|
|
60
|
-
* if the state dir isn't writable the returned epoch is still `>= now` (correct
|
|
61
|
-
* in the common forward-clock case), and the failure is logged, not thrown.
|
|
62
|
-
*/
|
|
63
|
-
export function resolveIngestEpoch(opts: ResolveIngestEpochOptions = {}): number {
|
|
64
|
-
const epochFile = opts.epochFile ?? DEFAULT_EPOCH_FILE;
|
|
65
|
-
const now = opts.now ?? Date.now();
|
|
66
|
-
|
|
67
|
-
const last = readLastEpoch(epochFile);
|
|
68
|
-
const epoch = Math.max(now, last + 1);
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
mkdirSync(dirname(epochFile), { recursive: true });
|
|
72
|
-
writeFileSync(epochFile, JSON.stringify({ epoch }));
|
|
73
|
-
} catch (err) {
|
|
74
|
-
opts.logger?.warn("memory ingest epoch: could not persist monotonic epoch; a backward clock across a future restart could drop captures", { err: String(err) });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return epoch;
|
|
78
|
-
}
|