loom-claw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * LoomContextEngine — OpenClaw Context Engine backed by the Loom Python backend.
3
+ *
4
+ * This is a thin integration layer. All cognitive memory logic (schema management,
5
+ * CM agent, SCP protocol, LLM calls) runs in the Loom FastAPI server.
6
+ * This engine just calls the right API endpoints at the right lifecycle hooks.
7
+ *
8
+ * Lifecycle mapping:
9
+ * bootstrap → verify Loom backend is reachable, ensure session exists
10
+ * ingest → detect /schema <name> command to switch active schema
11
+ * assemble → POST /api/schemas/recall (selective) → fallback GET /api/schemas/recall-all → inject as systemPromptAddition
12
+ * afterTurn → POST /api/build → extract info from new messages into schemas
13
+ * compact → delegate to runtime (schema memory persists independently)
14
+ * dispose → no-op (Loom backend handles persistence)
15
+ */
16
+
17
+ import type {
18
+ ContextEngine,
19
+ ContextEngineInfo,
20
+ ContextEngineRuntimeContext,
21
+ } from "openclaw/plugin-sdk";
22
+ import type { PluginLogger } from "openclaw/plugin-sdk";
23
+
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ type AnyMessage = any;
26
+
27
+ import { LoomClient } from "./client.js";
28
+ import type { LoomPluginConfig } from "./types.js";
29
+
30
+ interface Logger {
31
+ info: (message: string) => void;
32
+ warn: (message: string) => void;
33
+ error: (message: string) => void;
34
+ }
35
+
36
+ const SCHEMA_CONTEXT_HEADER =
37
+ "## Loom Persistent Memory (Schema Data)\n" +
38
+ "The following information has been accumulated about the user across conversations.\n" +
39
+ "Use it to provide personalized, context-aware responses.\n\n";
40
+
41
+ export class LoomContextEngine implements ContextEngine {
42
+ readonly info: ContextEngineInfo = {
43
+ id: "loom-claw",
44
+ name: "Loom Cognitive Memory Engine",
45
+ version: "0.1.0",
46
+ ownsCompaction: false,
47
+ };
48
+
49
+ private config: LoomPluginConfig;
50
+ private client: LoomClient;
51
+ private log: Logger;
52
+ private turnCounts: Map<string, number> = new Map();
53
+ private pendingBuildText: Map<string, string[]> = new Map();
54
+ private sessionIdMap: Map<string, string> = new Map();
55
+ private activeSchemaId: Map<string, string> = new Map();
56
+ private backendReachable = false;
57
+ private cachedSchemaText: string | null = null;
58
+ private currentOpenclawSessionId: string | null = null;
59
+ private currentLoomSessionId: string | null = null;
60
+
61
+ constructor(config: LoomPluginConfig, logger?: Logger) {
62
+ this.config = config;
63
+ this.client = new LoomClient(config.loomBaseUrl);
64
+ this.log = logger || {
65
+ info: (...args: unknown[]) => console.log("[loom]", ...args),
66
+ warn: (...args: unknown[]) => console.warn("[loom]", ...args),
67
+ error: (...args: unknown[]) => console.error("[loom]", ...args),
68
+ };
69
+ }
70
+
71
+ private getSchemaId(openclawSessionId: string): string {
72
+ return this.activeSchemaId.get(openclawSessionId) || this.config.schemaId;
73
+ }
74
+
75
+ private generateSessionId(): string {
76
+ const now = new Date();
77
+ const p = (n: number) => String(n).padStart(2, "0");
78
+ const ts =
79
+ `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}_` +
80
+ `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
81
+ return `session_${ts}`;
82
+ }
83
+
84
+ async bootstrap(params: {
85
+ sessionId: string;
86
+ sessionKey?: string;
87
+ sessionFile: string;
88
+ }) {
89
+ this.backendReachable = await this.client.ping();
90
+ if (!this.backendReachable) {
91
+ this.log.warn(
92
+ `Loom backend not reachable at ${this.config.loomBaseUrl}. ` +
93
+ "Schema memory will not be available until the backend is started.",
94
+ );
95
+ return { bootstrapped: false, reason: "backend_unreachable" };
96
+ }
97
+
98
+ this.log.info(`Connected to Loom backend at ${this.config.loomBaseUrl}`);
99
+
100
+ if (!this.sessionIdMap.has(params.sessionId)) {
101
+ this.sessionIdMap.set(params.sessionId, this.generateSessionId());
102
+ }
103
+ const loomSessionId = this.sessionIdMap.get(params.sessionId)!;
104
+ this.currentOpenclawSessionId = params.sessionId;
105
+ this.currentLoomSessionId = loomSessionId;
106
+ try {
107
+ await this.client.createSession(loomSessionId, this.config.schemaId, params.sessionId);
108
+ } catch {
109
+ // 409 = session already exists, which is fine
110
+ }
111
+
112
+ const schemaId = this.getSchemaId(params.sessionId);
113
+ try {
114
+ const result = await this.client.recallAll(schemaId);
115
+ this.cachedSchemaText = result.text;
116
+ } catch {
117
+ this.cachedSchemaText = null;
118
+ }
119
+
120
+ return { bootstrapped: true };
121
+ }
122
+
123
+ async ingest(params: {
124
+ sessionId: string;
125
+ sessionKey?: string;
126
+ message: AnyMessage;
127
+ isHeartbeat?: boolean;
128
+ }) {
129
+ if (params.isHeartbeat || !this.backendReachable) return { ingested: false };
130
+
131
+ const msg = params.message as Record<string, unknown>;
132
+ const text = extractTextContent(msg.content).trim();
133
+ const match = text.match(/^\/schema\s+(\S+)/);
134
+ if (match) {
135
+ const targetSchemaId = match[1];
136
+ try {
137
+ await this.handleSchemaSwitch(targetSchemaId);
138
+ } catch (e) {
139
+ this.log.error(`Failed to switch schema to '${targetSchemaId}': ${e}`);
140
+ }
141
+ }
142
+
143
+ return { ingested: false };
144
+ }
145
+
146
+ async assemble(params: {
147
+ sessionId: string;
148
+ sessionKey?: string;
149
+ messages: AnyMessage[];
150
+ tokenBudget?: number;
151
+ model?: string;
152
+ prompt?: string;
153
+ }) {
154
+ let schemaText = this.cachedSchemaText || "";
155
+ const schemaId = this.getSchemaId(params.sessionId);
156
+ const loomSessionId = this.sessionIdMap.get(params.sessionId) || params.sessionId;
157
+
158
+ if (this.backendReachable) {
159
+ const userMessage = this._extractLatestUserMessage(params.messages);
160
+
161
+ if (userMessage) {
162
+ try {
163
+ const result = await this.client.recall(userMessage, loomSessionId, schemaId);
164
+ schemaText = result.recalled;
165
+ this.cachedSchemaText = schemaText;
166
+ if (result.is_selective) {
167
+ this.log.info("Selective recall succeeded");
168
+ } else {
169
+ this.log.info("Selective recall empty, fell back to full schema");
170
+ }
171
+ } catch (e) {
172
+ this.log.warn(`Selective recall failed, falling back to recall-all: ${e}`);
173
+ try {
174
+ const fallback = await this.client.recallAll(schemaId);
175
+ schemaText = fallback.text;
176
+ this.cachedSchemaText = schemaText;
177
+ } catch (e2) {
178
+ this.log.warn(`recall-all also failed, using cache: ${e2}`);
179
+ }
180
+ }
181
+ } else {
182
+ try {
183
+ const result = await this.client.recallAll(schemaId);
184
+ schemaText = result.text;
185
+ this.cachedSchemaText = schemaText;
186
+ } catch (e) {
187
+ this.log.warn(`Failed to recall schemas, using cache: ${e}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ const systemPromptAddition = schemaText.trim()
193
+ ? SCHEMA_CONTEXT_HEADER + schemaText + "\n\n---"
194
+ : undefined;
195
+
196
+ return {
197
+ messages: params.messages,
198
+ estimatedTokens: 0,
199
+ systemPromptAddition,
200
+ };
201
+ }
202
+
203
+ async afterTurn(params: {
204
+ sessionId: string;
205
+ sessionKey?: string;
206
+ sessionFile: string;
207
+ messages: AnyMessage[];
208
+ prePromptMessageCount: number;
209
+ autoCompactionSummary?: string;
210
+ isHeartbeat?: boolean;
211
+ tokenBudget?: number;
212
+ runtimeContext?: ContextEngineRuntimeContext;
213
+ }): Promise<void> {
214
+ if (params.isHeartbeat || !this.backendReachable) return;
215
+
216
+ const turnKey = params.sessionId;
217
+ const count = (this.turnCounts.get(turnKey) || 0) + 1;
218
+ this.turnCounts.set(turnKey, count);
219
+
220
+ const allConversationMessages = params.messages.slice(params.prePromptMessageCount);
221
+
222
+ // Find the last user message and take everything from it to the end.
223
+ // A single turn may contain: [user] -> [assistant tool_call] -> [tool result] -> [assistant reply]
224
+ // Using slice(-2) would miss the user message when tools are involved.
225
+ let lastUserIdx = -1;
226
+ for (let i = allConversationMessages.length - 1; i >= 0; i--) {
227
+ if ((allConversationMessages[i] as Record<string, unknown>)?.role === "user") {
228
+ lastUserIdx = i;
229
+ break;
230
+ }
231
+ }
232
+ const turnMessages = lastUserIdx >= 0
233
+ ? allConversationMessages.slice(lastUserIdx)
234
+ : allConversationMessages.slice(-2);
235
+ const turnText = formatMessagesForBuild(turnMessages);
236
+
237
+ this.log.info(
238
+ `[afterTurn] turn ${count}/${this.config.buildEveryNTurns}, ` +
239
+ `totalMsgs=${params.messages.length}, prePrompt=${params.prePromptMessageCount}, ` +
240
+ `convMsgs=${allConversationMessages.length}, lastUserIdx=${lastUserIdx}, ` +
241
+ `turnMsgs=${turnMessages.length}, ` +
242
+ `textLen=${turnText.length}, preview=${JSON.stringify(turnText.slice(0, 200))}`,
243
+ );
244
+
245
+ if (turnText.trim()) {
246
+ const pending = this.pendingBuildText.get(turnKey) || [];
247
+ pending.push(turnText);
248
+ this.pendingBuildText.set(turnKey, pending);
249
+ } else {
250
+ this.log.warn(`[afterTurn] turn ${count}: turnText is EMPTY, skipping accumulation`);
251
+ }
252
+
253
+ if (count % this.config.buildEveryNTurns !== 0) return;
254
+
255
+ const accumulated = this.pendingBuildText.get(turnKey) || [];
256
+ if (accumulated.length === 0) {
257
+ this.log.warn(`[afterTurn] turn ${count}: no accumulated text to build`);
258
+ return;
259
+ }
260
+
261
+ const text = accumulated.join("\n");
262
+
263
+ const loomSessionId = this.sessionIdMap.get(turnKey) || turnKey;
264
+ const sourceId = this.currentOpenclawSessionId || "";
265
+ this.log.info(`[afterTurn] BUILD sending ${text.length} chars to session=${loomSessionId}`);
266
+ try {
267
+ const result = await this.client.build(text, loomSessionId, sourceId);
268
+ this.log.info(
269
+ `Schema build completed (session=${loomSessionId}, turn ${count}, ` +
270
+ `${accumulated.length} segments, result=${JSON.stringify(result)})`,
271
+ );
272
+ this.pendingBuildText.set(turnKey, []);
273
+ } catch (e) {
274
+ this.log.error(`Schema build failed: ${e}`);
275
+ }
276
+ }
277
+
278
+ async compact(_params: {
279
+ sessionId: string;
280
+ sessionKey?: string;
281
+ sessionFile: string;
282
+ tokenBudget?: number;
283
+ force?: boolean;
284
+ currentTokenCount?: number;
285
+ compactionTarget?: "budget" | "threshold";
286
+ customInstructions?: string;
287
+ runtimeContext?: ContextEngineRuntimeContext;
288
+ }) {
289
+ return {
290
+ ok: true,
291
+ compacted: false,
292
+ reason: "loom-claw delegates compaction to the runtime; schema data persists independently",
293
+ };
294
+ }
295
+
296
+ async dispose(): Promise<void> {
297
+ this.cachedSchemaText = null;
298
+ }
299
+
300
+ private _extractLatestUserMessage(messages: AnyMessage[]): string {
301
+ for (let i = messages.length - 1; i >= 0; i--) {
302
+ const msg = messages[i] as Record<string, unknown>;
303
+ if (msg.role === "user") {
304
+ return extractTextContent(msg.content);
305
+ }
306
+ }
307
+ return "";
308
+ }
309
+
310
+ getClient(): LoomClient {
311
+ return this.client;
312
+ }
313
+
314
+ getConfig(): LoomPluginConfig {
315
+ return this.config;
316
+ }
317
+
318
+ updateConfig(patch: Partial<LoomPluginConfig>): void {
319
+ this.config = { ...this.config, ...patch };
320
+ }
321
+
322
+ getLoomSessionId(): string {
323
+ return this.currentLoomSessionId || this.config.sessionId;
324
+ }
325
+
326
+ async handleSchemaSwitch(targetSchemaId: string): Promise<{ status: string; message?: string }> {
327
+ const loomSessionId = this.getLoomSessionId();
328
+ const sourceId = this.currentOpenclawSessionId || "";
329
+ let result: { status: string; message?: string };
330
+ try {
331
+ result = await this.client.switchSchema(loomSessionId, targetSchemaId, sourceId);
332
+ } catch (e) {
333
+ this.log.warn(`Backend switch failed, updating local state anyway: ${e}`);
334
+ result = { status: "ok", message: "local-only switch (backend call failed)" };
335
+ }
336
+ this.onSchemaChanged(targetSchemaId);
337
+ this.log.info(`Switched session ${loomSessionId} to schema '${targetSchemaId}'`);
338
+ return result;
339
+ }
340
+
341
+ onSchemaChanged(targetSchemaId: string): void {
342
+ this.config = { ...this.config, schemaId: targetSchemaId };
343
+ if (this.currentOpenclawSessionId) {
344
+ this.activeSchemaId.set(this.currentOpenclawSessionId, targetSchemaId);
345
+ }
346
+ this.cachedSchemaText = null;
347
+ }
348
+
349
+ isReachable(): boolean {
350
+ return this.backendReachable;
351
+ }
352
+
353
+ setReachable(reachable: boolean): void {
354
+ this.backendReachable = reachable;
355
+ }
356
+ }
357
+
358
+ function formatMessagesForBuild(messages: AnyMessage[]): string {
359
+ const lines: string[] = [];
360
+ for (const msg of messages) {
361
+ const m = msg as Record<string, unknown>;
362
+ const role = m.role || "unknown";
363
+ const content = extractTextContent(m.content);
364
+ if (content.trim()) {
365
+ lines.push(`[${role}]: ${content}`);
366
+ }
367
+ }
368
+ return lines.join("\n");
369
+ }
370
+
371
+ function extractTextContent(content: unknown): string {
372
+ if (typeof content === "string") return content;
373
+ if (Array.isArray(content)) {
374
+ return content
375
+ .map((part: unknown) => {
376
+ if (typeof part === "string") return part;
377
+ if (typeof part === "object" && part !== null) {
378
+ const p = part as Record<string, unknown>;
379
+ if (p.type === "text" && typeof p.text === "string") return p.text;
380
+ }
381
+ return "";
382
+ })
383
+ .filter(Boolean)
384
+ .join("\n");
385
+ }
386
+ return "";
387
+ }
package/src/types.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Type definitions for the Loom OpenClaw plugin.
3
+ */
4
+
5
+ export interface LoomPluginConfig {
6
+ enabled: boolean;
7
+ loomBaseUrl: string;
8
+ sessionId: string;
9
+ schemaId: string;
10
+ schemaTemplate: string;
11
+ buildEveryNTurns: number;
12
+ }
13
+
14
+ export interface BuildResponse {
15
+ status: string;
16
+ session_id: string;
17
+ message: string;
18
+ }
19
+
20
+ export interface RecallAllResponse {
21
+ schema_id: string;
22
+ text: string;
23
+ }
24
+
25
+ export interface RecallResponse {
26
+ recalled: string;
27
+ is_selective: boolean;
28
+ schema_id: string;
29
+ }
30
+
31
+ export interface InspectAllResponse {
32
+ schema_id: string;
33
+ text: string;
34
+ }
35
+
36
+ export interface SchemaInfo {
37
+ name: string;
38
+ field_count: number;
39
+ fields: Record<string, string>;
40
+ }
41
+
42
+ export interface SchemaFileDataResponse {
43
+ schema_id: string;
44
+ domains: Array<{
45
+ name: string;
46
+ field_count: number;
47
+ fields: Record<string, string>;
48
+ }>;
49
+ }
50
+
51
+ export interface SessionInfo {
52
+ session_id: string;
53
+ schema_id: string;
54
+ file_size: number;
55
+ chat_rounds: number;
56
+ schema_count: number;
57
+ }