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/index.ts ADDED
@@ -0,0 +1,1164 @@
1
+ /**
2
+ * loom-claw — Loom cognitive memory as an OpenClaw Context Engine plugin.
3
+ *
4
+ * Connects OpenClaw to a running Loom Python backend for structured
5
+ * schema-based long-term memory. Registers:
6
+ * - A Context Engine (assemble/afterTurn lifecycle)
7
+ * - Agent tools (loom_inspect, loom_build, loom_forget, loom_update)
8
+ * - Slash commands (/loom status|inspect|forget|reset|templates|config)
9
+ */
10
+
11
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
12
+ import { Type } from "@sinclair/typebox";
13
+ import { LoomContextEngine } from "./src/engine.js";
14
+ import { LoomClient } from "./src/client.js";
15
+ import { resolveConfig } from "./src/config.js";
16
+ import type { LoomPluginConfig } from "./src/types.js";
17
+
18
+ /** Telegram messages are capped at ~4096 chars; leave buffer for formatting. */
19
+ const INSPECT_MAX_LENGTH = 3500;
20
+
21
+ /** Helper: wrap a text string into AgentToolResult format. */
22
+ function textToolResult(text: string) {
23
+ return {
24
+ content: [{ type: "text" as const, text }],
25
+ details: { text },
26
+ };
27
+ }
28
+
29
+ function extractPluginConfig(api: OpenClawPluginApi): LoomPluginConfig {
30
+ const fullConfig = api.config as Record<string, unknown>;
31
+ const plugins = fullConfig?.plugins as Record<string, unknown> | undefined;
32
+ const entries = plugins?.entries as Record<string, Record<string, unknown>> | undefined;
33
+ const entry = entries?.["loom-claw"];
34
+ if (entry) {
35
+ return resolveConfig(process.env, {
36
+ ...((entry.config as Record<string, unknown>) || {}),
37
+ enabled: entry.enabled,
38
+ });
39
+ }
40
+ return resolveConfig(process.env, {});
41
+ }
42
+
43
+ const loomPlugin = {
44
+ id: "loom-claw",
45
+ name: "Loom Cognitive Memory",
46
+ description:
47
+ "Connects OpenClaw to a Loom backend for structured schema-based long-term memory. " +
48
+ "The Loom CM agent automatically extracts key information from conversations " +
49
+ "into structured schemas, then injects relevant data back into the model context.",
50
+
51
+ configSchema: {
52
+ parse(value: unknown): LoomPluginConfig {
53
+ const raw =
54
+ value && typeof value === "object" && !Array.isArray(value)
55
+ ? (value as Record<string, unknown>)
56
+ : {};
57
+ return resolveConfig(process.env, raw);
58
+ },
59
+ },
60
+
61
+ register(api: OpenClawPluginApi) {
62
+ const config = extractPluginConfig(api);
63
+
64
+ if (!config.enabled) {
65
+ api.logger.info("[loom-claw] Plugin disabled via config");
66
+ return;
67
+ }
68
+
69
+ const engine = new LoomContextEngine(config, api.logger);
70
+
71
+ api.registerContextEngine("loom-claw", () => engine);
72
+
73
+ // -- Agent tools --
74
+ api.registerTool(() => createLoomInspectTool(engine), { name: "loom_inspect" });
75
+ api.registerTool(() => createLoomBuildTool(engine), { name: "loom_build" });
76
+ api.registerTool(() => createLoomForgetTool(engine), { name: "loom_forget" });
77
+ api.registerTool(() => createLoomUpdateTool(engine), { name: "loom_update" });
78
+
79
+ // -- Slash commands --
80
+ api.registerCommand({
81
+ name: "loom",
82
+ description: "Loom memory management — /loom help for all commands",
83
+ acceptsArgs: true,
84
+ requireAuth: true,
85
+ handler: createLoomCommandHandler(engine),
86
+ });
87
+
88
+ api.logger.info(
89
+ `[loom-claw] Plugin loaded (backend=${config.loomBaseUrl}, ` +
90
+ `schema=${config.schemaId}, buildEvery=${config.buildEveryNTurns} turns)`,
91
+ );
92
+ },
93
+ };
94
+
95
+ export default loomPlugin;
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Slash command handler
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function createLoomCommandHandler(engine: LoomContextEngine) {
102
+ return async (ctx: { args?: string }) => {
103
+ const client = engine.getClient();
104
+ const cfg = engine.getConfig();
105
+ const args = (ctx.args || "").trim();
106
+ const [subcommand, ...rest] = args.split(/\s+/);
107
+ const subArgs = rest.join(" ").trim();
108
+
109
+ if (!engine.isReachable()) {
110
+ const ok = await client.ping();
111
+ if (ok) engine.setReachable(true);
112
+ }
113
+
114
+ if (!engine.isReachable()) {
115
+ return {
116
+ text: `⚠️ Loom backend unreachable (${cfg.loomBaseUrl})\nMake sure the Loom service is running.`,
117
+ };
118
+ }
119
+
120
+ switch (subcommand.toLowerCase()) {
121
+ case "status":
122
+ case "": {
123
+ try {
124
+ const schemas = await client.listSchemas();
125
+ const sessions = await client.listSessions();
126
+ const lines = [
127
+ "🧠 **Loom status**",
128
+ `- Backend: ${cfg.loomBaseUrl} ✅`,
129
+ `- Schema ID: \`${cfg.schemaId}\``,
130
+ `- Session ID: \`${engine.getLoomSessionId()}\``,
131
+ `- Schema domains: ${schemas.length ? schemas.join(", ") : "(empty)"}`,
132
+ `- Sessions: ${sessions.length}`,
133
+ `- Auto-extract: every ${cfg.buildEveryNTurns} turn(s)`,
134
+ ];
135
+ return { text: lines.join("\n") };
136
+ } catch (e) {
137
+ return { text: `❌ Failed to get status: ${e}` };
138
+ }
139
+ }
140
+
141
+ case "inspect": {
142
+ try {
143
+ const fullResult = await client.inspectAll(cfg.schemaId, true, -1);
144
+ const fullText = fullResult.text || "";
145
+
146
+ if (!fullText) {
147
+ return { text: "📭 Memory is empty" };
148
+ }
149
+
150
+ if (fullText.length <= INSPECT_MAX_LENGTH) {
151
+ return { text: fullText };
152
+ }
153
+
154
+ const summaryResult = await client.inspectAll(cfg.schemaId, false, -1);
155
+ const summaryText = summaryResult.text || "";
156
+ const lines = [
157
+ "⚠️ Memory content is long; showing structure summary only:",
158
+ "",
159
+ summaryText,
160
+ "",
161
+ `💡 Full content is ${fullText.length} characters`,
162
+ ];
163
+ let text = lines.join("\n");
164
+ if (text.length > INSPECT_MAX_LENGTH) {
165
+ text = text.slice(0, INSPECT_MAX_LENGTH) + "\n\n… (truncated)";
166
+ }
167
+ return { text };
168
+ } catch (e) {
169
+ return { text: `❌ inspect failed: ${e}` };
170
+ }
171
+ }
172
+
173
+ case "forget": {
174
+ if (!subArgs) {
175
+ return {
176
+ text: "Usage: `/loom forget <domain>` or `/loom forget --all`\nExample: `/loom forget identity`",
177
+ };
178
+ }
179
+ try {
180
+ if (subArgs === "--all") {
181
+ const result = await client.clearAllSchemas(cfg.schemaId);
182
+ return { text: `🗑️ Cleared all schemas (${result.count} domain(s))` };
183
+ }
184
+ await client.deleteSchema(subArgs, cfg.schemaId);
185
+ return { text: `🗑️ Deleted domain: \`${subArgs}\`` };
186
+ } catch (e) {
187
+ return { text: `❌ forget failed: ${e}` };
188
+ }
189
+ }
190
+
191
+ case "reset": {
192
+ try {
193
+ const result = await client.newSchema(engine.getLoomSessionId());
194
+ const msg = result.backup_id
195
+ ? `🔄 Schema reset (backup: ${result.backup_id})`
196
+ : "🔄 Schema reset";
197
+ return { text: msg };
198
+ } catch (e) {
199
+ return { text: `❌ reset failed: ${e}` };
200
+ }
201
+ }
202
+
203
+ case "new": {
204
+ const newName = subArgs.trim();
205
+ try {
206
+ const loomSessionId = engine.getLoomSessionId();
207
+ const result = await client.createSchemaFile(loomSessionId, newName);
208
+ engine.onSchemaChanged(result.schema_id);
209
+ const lines = [
210
+ `✅ Created and switched to new schema: \`${result.schema_id}\``,
211
+ `Use \`/loom schemas\` to list schemas`,
212
+ "",
213
+ "💡 To create from a template: `/loom templates use <name>`",
214
+ ];
215
+ return { text: lines.join("\n") };
216
+ } catch (e) {
217
+ return { text: `❌ create failed: ${e}` };
218
+ }
219
+ }
220
+
221
+ case "templates": {
222
+ const templateSub = rest[0]?.toLowerCase() || "";
223
+
224
+ if (!templateSub || templateSub === "list") {
225
+ return handleTemplateList(client);
226
+ }
227
+
228
+ if (templateSub === "show" || templateSub === "preview") {
229
+ const tmplName = rest[1]?.trim();
230
+ if (!tmplName) {
231
+ return {
232
+ text: "Usage: `/loom templates show <name>`\nUse `/loom templates` to list templates",
233
+ };
234
+ }
235
+ return handleTemplateShow(client, tmplName);
236
+ }
237
+
238
+ if (templateSub === "use" || templateSub === "apply") {
239
+ const tmplName = rest[1]?.trim();
240
+ if (!tmplName) {
241
+ return {
242
+ text:
243
+ "Usage: `/loom templates use <name> [schema_name]`\nCreates a new schema from the template and switches to it. Schema name is optional.\nUse `/loom templates` to list templates",
244
+ };
245
+ }
246
+ const schemaName = rest[2]?.trim() || "";
247
+ return handleTemplateApply(client, engine, tmplName, schemaName);
248
+ }
249
+
250
+ if (templateSub === "create" || templateSub === "new") {
251
+ const wizardSub = rest[1]?.toLowerCase() || "";
252
+ const wizardArgs = rest.slice(2).join(" ").trim();
253
+ const fullArgs = rest.slice(1).join(" ").trim();
254
+ return handleTemplateCreateWizard(client, wizardSub, wizardArgs, fullArgs);
255
+ }
256
+
257
+ if (templateSub === "delete" || templateSub === "remove") {
258
+ const tmplName = rest[1]?.trim();
259
+ if (!tmplName) {
260
+ return {
261
+ text: "Usage: `/loom templates delete <name>`\n⚠️ Only custom templates can be deleted",
262
+ };
263
+ }
264
+ return handleTemplateDelete(client, tmplName);
265
+ }
266
+
267
+ if (templateSub === "help") {
268
+ return { text: formatTemplateHelp() };
269
+ }
270
+
271
+ return { text: `❌ Unknown templates subcommand: \`${templateSub}\`\n\n` + formatTemplateHelp() };
272
+ }
273
+
274
+ case "schemas":
275
+ case "list": {
276
+ try {
277
+ const saved = await client.listSavedSchemas();
278
+ const backups = await client.listBackups();
279
+ const lines: string[] = ["📂 **Schema files**"];
280
+ if (!saved.length) {
281
+ lines.push("(no saved schemas)");
282
+ } else {
283
+ for (const s of saved) {
284
+ const active = s.schema_id === cfg.schemaId ? " ← current" : "";
285
+ lines.push(`- \`${s.schema_id}\` (${s.domain_count} domains, ${s.file_size}B)${active}`);
286
+ }
287
+ }
288
+ if (backups.length) {
289
+ lines.push("", "📦 **Backups**");
290
+ for (const b of backups) {
291
+ lines.push(`- \`${b.backup_id}\` (${b.file_size}B, ${b.created_at})`);
292
+ }
293
+ }
294
+ lines.push("", "Switch: `/loom switch <schema_id>`");
295
+ lines.push("Restore: `/loom restore <backup_id>`");
296
+ return { text: lines.join("\n") };
297
+ } catch (e) {
298
+ return { text: `❌ Failed to list schemas: ${e}` };
299
+ }
300
+ }
301
+
302
+ case "switch": {
303
+ if (!subArgs) {
304
+ return { text: "Usage: `/loom switch <schema_id>`\nUse `/loom schemas` for available schemas" };
305
+ }
306
+ try {
307
+ await engine.handleSchemaSwitch(subArgs);
308
+ return { text: `✅ Switched to schema: \`${subArgs}\`` };
309
+ } catch (e) {
310
+ return { text: `❌ switch failed: ${e}` };
311
+ }
312
+ }
313
+
314
+ case "restore": {
315
+ if (!subArgs) {
316
+ return { text: "Usage: `/loom restore <backup_id>`\nUse `/loom schemas` for backups" };
317
+ }
318
+ try {
319
+ await client.restoreSchema(subArgs, cfg.schemaId);
320
+ return { text: `✅ Restored from backup: \`${subArgs}\` → \`${cfg.schemaId}\`` };
321
+ } catch (e) {
322
+ return { text: `❌ restore failed: ${e}` };
323
+ }
324
+ }
325
+
326
+ case "config": {
327
+ if (!subArgs) {
328
+ const lines = [
329
+ "⚙️ **Current config**",
330
+ `- \`buildEveryNTurns\` = ${cfg.buildEveryNTurns} (extract every ${cfg.buildEveryNTurns} turn(s))`,
331
+ `- \`loomBaseUrl\` = ${cfg.loomBaseUrl}`,
332
+ `- \`sessionId\` = ${cfg.sessionId} (active: ${engine.getLoomSessionId()})`,
333
+ `- \`schemaId\` = ${cfg.schemaId}`,
334
+ "",
335
+ "Change: `/loom config buildEveryNTurns <number>`",
336
+ ];
337
+ return { text: lines.join("\n") };
338
+ }
339
+ const configParts = subArgs.split(/\s+/);
340
+ const key = configParts[0];
341
+ const value = configParts.slice(1).join(" ");
342
+
343
+ if (key === "buildEveryNTurns") {
344
+ const n = parseInt(value, 10);
345
+ if (!value || isNaN(n) || n < 1) {
346
+ return {
347
+ text: `❌ Provide a positive integer.\nUsage: \`/loom config buildEveryNTurns 3\``,
348
+ };
349
+ }
350
+ engine.updateConfig({ buildEveryNTurns: n });
351
+ return {
352
+ text: `✅ Extraction interval updated to every **${n}** turn(s)\n⚠️ Runtime only; restarting the gateway restores values from config`,
353
+ };
354
+ }
355
+
356
+ return { text: `❌ Cannot change \`${key}\`.\nConfigurable: \`buildEveryNTurns\`` };
357
+ }
358
+
359
+ case "help":
360
+ default: {
361
+ return {
362
+ text: [
363
+ "🧠 **Loom memory commands**",
364
+ "",
365
+ "**View**",
366
+ "`/loom` — status",
367
+ "`/loom inspect` — memory content (summary if too long)",
368
+ "",
369
+ "**Manage**",
370
+ "`/loom forget <domain>` — delete a domain",
371
+ "`/loom forget --all` — clear all memory",
372
+ "`/loom reset` — backup and reset current schema",
373
+ "",
374
+ "**Schema files**",
375
+ "`/loom schemas` — list schemas and backups",
376
+ "`/loom new [name]` — create blank schema and switch",
377
+ "`/loom switch <id>` — switch schema",
378
+ "`/loom restore <backup_id>` — restore from backup",
379
+ "",
380
+ "**Templates**",
381
+ "`/loom templates` — list templates",
382
+ "`/loom templates show <name>` — preview template fields",
383
+ "`/loom templates use <name> [schema]` — create schema from template",
384
+ "`/loom templates create` — wizard for custom template",
385
+ "`/loom templates delete <name>` — delete custom template",
386
+ "",
387
+ "**Config**",
388
+ "`/loom config` — current settings",
389
+ "`/loom config buildEveryNTurns <N>` — extraction interval",
390
+ ].join("\n"),
391
+ };
392
+ }
393
+ }
394
+ };
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Agent tools
399
+ // ---------------------------------------------------------------------------
400
+
401
+ function createLoomInspectTool(engine: LoomContextEngine) {
402
+ return {
403
+ name: "loom_inspect",
404
+ label: "Loom Inspect",
405
+ description:
406
+ "Inspect the Loom memory schema structure. Returns an overview of all stored " +
407
+ "memory domains and their fields with values.",
408
+ parameters: Type.Object({
409
+ show_values: Type.Optional(Type.Boolean({ description: "Whether to include field values (default: true)" })),
410
+ }),
411
+ async execute(_toolCallId: string, params: { show_values?: boolean }) {
412
+ if (!engine.isReachable()) return textToolResult("Loom backend is not reachable.");
413
+ try {
414
+ const result = await engine.getClient().inspectAll(
415
+ engine.getConfig().schemaId,
416
+ params.show_values !== false,
417
+ );
418
+ return textToolResult(result.text || "Memory schemas are empty.");
419
+ } catch (e) {
420
+ return textToolResult(`Failed to inspect schemas: ${e}`);
421
+ }
422
+ },
423
+ };
424
+ }
425
+
426
+ function createLoomBuildTool(engine: LoomContextEngine) {
427
+ return {
428
+ name: "loom_build",
429
+ label: "Loom Build",
430
+ description:
431
+ "Manually trigger Loom to extract and store information from the provided text " +
432
+ "into structured memory schemas. Use when the user shares important personal " +
433
+ "information that should be remembered long-term.",
434
+ parameters: Type.Object({
435
+ text: Type.String({ description: "Text to process and extract information from" }),
436
+ }),
437
+ async execute(_toolCallId: string, params: { text: string }) {
438
+ if (!engine.isReachable()) return textToolResult("Loom backend is not reachable.");
439
+ try {
440
+ await engine.getClient().build(params.text, engine.getLoomSessionId());
441
+ return textToolResult("Memory updated successfully.");
442
+ } catch (e) {
443
+ return textToolResult(`Failed to build memory: ${e}`);
444
+ }
445
+ },
446
+ };
447
+ }
448
+
449
+ function createLoomForgetTool(engine: LoomContextEngine) {
450
+ return {
451
+ name: "loom_forget",
452
+ label: "Loom Forget",
453
+ description:
454
+ "Delete a schema domain from Loom memory. Use when the user asks to forget " +
455
+ "specific information. Pass the domain name (e.g. 'identity', 'preferences').",
456
+ parameters: Type.Object({
457
+ domain: Type.String({ description: "Domain name to delete (e.g. 'identity', 'preferences'). Use '--all' to clear everything." }),
458
+ }),
459
+ async execute(_toolCallId: string, params: { domain: string }) {
460
+ if (!engine.isReachable()) return textToolResult("Loom backend is not reachable.");
461
+ try {
462
+ const client = engine.getClient();
463
+ const schemaId = engine.getConfig().schemaId;
464
+ if (params.domain === "--all") {
465
+ const result = await client.clearAllSchemas(schemaId);
466
+ return textToolResult(`All schemas cleared (${result.count} domains removed).`);
467
+ }
468
+ await client.deleteSchema(params.domain, schemaId);
469
+ return textToolResult(`Domain '${params.domain}' deleted successfully.`);
470
+ } catch (e) {
471
+ return textToolResult(`Failed to forget: ${e}`);
472
+ }
473
+ },
474
+ };
475
+ }
476
+
477
+ function createLoomUpdateTool(engine: LoomContextEngine) {
478
+ return {
479
+ name: "loom_update",
480
+ label: "Loom Update",
481
+ description:
482
+ "Trigger schema update from the Loom session's recent chat history. " +
483
+ "Useful after a long conversation to ensure all important info is captured.",
484
+ parameters: Type.Object({
485
+ rounds: Type.Optional(Type.Number({ description: "Number of recent chat rounds to process (default: all available)" })),
486
+ }),
487
+ async execute(_toolCallId: string, params: { rounds?: number }) {
488
+ if (!engine.isReachable()) return textToolResult("Loom backend is not reachable.");
489
+ try {
490
+ await engine.getClient().updateSchemaFromChat(
491
+ engine.getLoomSessionId(),
492
+ params.rounds,
493
+ );
494
+ return textToolResult("Schema updated from chat history.");
495
+ } catch (e) {
496
+ return textToolResult(`Failed to update schema: ${e}`);
497
+ }
498
+ },
499
+ };
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Template management handlers
504
+ // ---------------------------------------------------------------------------
505
+
506
+ async function handleTemplateList(client: LoomClient) {
507
+ try {
508
+ const grouped = await client.listTemplatesGrouped();
509
+ const lines: string[] = ["📋 **Available templates**", ""];
510
+
511
+ const groupLabels: Record<string, string> = {
512
+ builtin: "🔒 Built-in",
513
+ user: "📁 Shared",
514
+ custom: "✏️ Custom",
515
+ };
516
+
517
+ let hasAny = false;
518
+ for (const [source, label] of Object.entries(groupLabels)) {
519
+ const items = grouped[source] || [];
520
+ if (items.length) {
521
+ hasAny = true;
522
+ lines.push(`**${label}**`);
523
+ for (const t of items) {
524
+ lines.push(`- \`${t.name}\` — ${t.description || "(no description)"} (${t.domain_count} domains)`);
525
+ }
526
+ lines.push("");
527
+ }
528
+ }
529
+
530
+ if (!hasAny) {
531
+ return { text: "📭 No templates available" };
532
+ }
533
+
534
+ lines.push(
535
+ "---",
536
+ "💡 **Next steps**",
537
+ "`/loom templates show <name>` — preview fields",
538
+ "`/loom templates use <name> [schema]` — create schema from template",
539
+ "`/loom templates create` — custom template wizard",
540
+ "`/loom templates delete <name>` — delete a custom template",
541
+ );
542
+
543
+ return { text: lines.join("\n") };
544
+ } catch (e) {
545
+ return { text: `❌ Failed to list templates: ${e}` };
546
+ }
547
+ }
548
+
549
+ async function handleTemplateShow(client: LoomClient, name: string) {
550
+ try {
551
+ const tmpl = await client.getTemplate(name);
552
+ const lines: string[] = [`📋 **Template: ${tmpl.name}**`, ""];
553
+
554
+ if (tmpl.description) {
555
+ lines.push(`> ${tmpl.description}`, "");
556
+ }
557
+
558
+ lines.push(`Source: \`${tmpl.source}\` | Domains: ${tmpl.domains.length}`, "");
559
+
560
+ const data = tmpl.data as Record<string, Record<string, unknown>>;
561
+ for (const domainName of tmpl.domains) {
562
+ const domain = data[domainName];
563
+ if (!domain || typeof domain !== "object") continue;
564
+ const domainData = (domain as Record<string, unknown>).data as Record<string, Record<string, unknown>> | undefined;
565
+ if (!domainData) continue;
566
+
567
+ const fields = Object.keys(domainData).filter((k) => k !== "meta");
568
+ lines.push(`**📂 ${domainName}** (${fields.length} field(s))`);
569
+ for (const field of fields) {
570
+ const desc = domainData[field]?.description || "";
571
+ lines.push(` - \`${field}\`: ${desc}`);
572
+ }
573
+ lines.push("");
574
+ }
575
+
576
+ lines.push("---", `Use \`/loom templates use ${name}\` to create a schema from this template`);
577
+ return { text: lines.join("\n") };
578
+ } catch (e) {
579
+ return { text: `❌ Failed to load template: ${e}` };
580
+ }
581
+ }
582
+
583
+ async function handleTemplateApply(
584
+ client: LoomClient,
585
+ engine: LoomContextEngine,
586
+ templateName: string,
587
+ schemaName = "",
588
+ ) {
589
+ try {
590
+ const loomSessionId = engine.getLoomSessionId();
591
+ const result = await client.createSchemaFile(loomSessionId, schemaName, templateName);
592
+ engine.onSchemaChanged(result.schema_id);
593
+
594
+ const lines = [
595
+ `✅ Created schema \`${result.schema_id}\` from template \`${templateName}\``,
596
+ "",
597
+ "Schema structure is ready; Loom will extract into these fields in later turns.",
598
+ "",
599
+ "`/loom inspect` — view structure",
600
+ "`/loom schemas` — list schemas",
601
+ ];
602
+ return { text: lines.join("\n") };
603
+ } catch (e) {
604
+ return { text: `❌ Failed to create schema from template: ${e}` };
605
+ }
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Template creation wizard — state and handlers
610
+ // ---------------------------------------------------------------------------
611
+
612
+ interface WizardDomain {
613
+ fields: Record<string, string>;
614
+ }
615
+
616
+ interface WizardState {
617
+ name: string;
618
+ description: string;
619
+ domains: Record<string, WizardDomain>;
620
+ createdAt: number;
621
+ }
622
+
623
+ const WIZARD_TIMEOUT_MS = 30 * 60 * 1000;
624
+ let _wizardState: WizardState | null = null;
625
+
626
+ function getWizard(): WizardState | null {
627
+ if (_wizardState && Date.now() - _wizardState.createdAt > WIZARD_TIMEOUT_MS) {
628
+ _wizardState = null;
629
+ }
630
+ return _wizardState;
631
+ }
632
+
633
+ function formatWizardStatus(w: WizardState): string {
634
+ const lines: string[] = [
635
+ "📋 **Template wizard — progress**",
636
+ "",
637
+ `Name: \`${w.name || "(not set)"}\``,
638
+ `Description: ${w.description || "(not set)"}`,
639
+ "",
640
+ ];
641
+
642
+ const domainNames = Object.keys(w.domains);
643
+ if (domainNames.length === 0) {
644
+ lines.push("Domains: (none yet)");
645
+ } else {
646
+ lines.push(`**Domains (${domainNames.length})**`);
647
+ for (const [dName, dData] of Object.entries(w.domains)) {
648
+ const fieldNames = Object.keys(dData.fields);
649
+ lines.push(`- \`${dName}\` — ${fieldNames.length} field(s): ${fieldNames.map((f) => `\`${f}\``).join(", ")}`);
650
+ }
651
+ }
652
+
653
+ lines.push("");
654
+ const canSave = w.name && domainNames.length > 0;
655
+ if (!w.name) {
656
+ lines.push("**Next:** set template name");
657
+ lines.push("`/loom templates create name <name> [description]`");
658
+ } else if (domainNames.length === 0) {
659
+ lines.push("**Next:** add at least one domain");
660
+ lines.push("`/loom templates create domain <domain> <field1|desc, field2|desc>`");
661
+ } else {
662
+ lines.push("**Optional:**");
663
+ lines.push("`/loom templates create domain <name> <fields...>` — add another domain");
664
+ lines.push("`/loom templates create remove <domain>` — remove a domain");
665
+ if (canSave) {
666
+ lines.push("`/loom templates create done` — **save template**");
667
+ }
668
+ }
669
+ lines.push("`/loom templates create cancel` — abort");
670
+
671
+ return lines.join("\n");
672
+ }
673
+
674
+ /**
675
+ * Wizard-based template creation handler. Supports sub-commands:
676
+ * (no args) → show wizard overview / start wizard
677
+ * name <n> [desc] → set template name and optional description
678
+ * desc <text> → set/update description
679
+ * domain <n> <fields>→ add a domain with fields
680
+ * remove <domain> → remove a domain
681
+ * done / save → save the template
682
+ * cancel → cancel wizard
683
+ * --json <data> → direct JSON creation (advanced)
684
+ *
685
+ * Also supports the legacy compact format for backward compatibility.
686
+ */
687
+ async function handleTemplateCreateWizard(
688
+ client: LoomClient,
689
+ sub: string,
690
+ subArgs: string,
691
+ fullArgs: string,
692
+ ) {
693
+ // -- Direct JSON mode (advanced) --
694
+ if (fullArgs.startsWith("--json ") || fullArgs.startsWith("--json=")) {
695
+ const jsonStr = fullArgs.startsWith("--json=") ? fullArgs.slice(7) : fullArgs.slice(7);
696
+ try {
697
+ const data = JSON.parse(jsonStr);
698
+ const result = await client.saveCustomTemplate(data);
699
+ return { text: `✅ Custom template saved: \`${result.name}\`\nFile: \`${result.path}\`` };
700
+ } catch (e) {
701
+ if (e instanceof SyntaxError) {
702
+ return { text: `❌ Invalid JSON: ${e.message}\nCheck the JSON syntax.` };
703
+ }
704
+ return { text: `❌ Failed to save template: ${e}` };
705
+ }
706
+ }
707
+
708
+ // -- Legacy compact format: detect multi-line or "name desc\n domain:..." pattern --
709
+ if (sub && !["name", "desc", "domain", "add", "remove", "rm", "done", "save", "cancel", "status", "help"].includes(sub)) {
710
+ if (fullArgs.includes("\n") || fullArgs.includes(":")) {
711
+ try {
712
+ const parsed = parseCompactTemplate(fullArgs);
713
+ const result = await client.saveCustomTemplate(parsed);
714
+ const domainCount = Object.keys(parsed).filter((k) => k !== "_meta").length;
715
+ const lines = [
716
+ `✅ Custom template saved: \`${result.name}\``,
717
+ `File: \`${result.path}\``,
718
+ `Domains: ${domainCount}`,
719
+ "",
720
+ `Preview: \`/loom templates show ${result.name}\``,
721
+ `Apply: \`/loom templates use ${result.name}\` on a new schema`,
722
+ ];
723
+ return { text: lines.join("\n") };
724
+ } catch (e) {
725
+ if (e instanceof TemplateParseError) {
726
+ return {
727
+ text: `❌ Parse error: ${e.message}\n\n💡 Try the wizard: \`/loom templates create\``,
728
+ };
729
+ }
730
+ return { text: `❌ Failed to save template: ${e}` };
731
+ }
732
+ }
733
+ }
734
+
735
+ // -- Wizard sub-commands --
736
+ switch (sub) {
737
+ case "": {
738
+ // Start new wizard or show status of existing one
739
+ const existing = getWizard();
740
+ if (existing) {
741
+ return { text: formatWizardStatus(existing) };
742
+ }
743
+ _wizardState = { name: "", description: "", domains: {}, createdAt: Date.now() };
744
+ return {
745
+ text: [
746
+ "✏️ **Template creation wizard**",
747
+ "",
748
+ "Create a custom template step by step.",
749
+ "",
750
+ "**Step 1:** set template name",
751
+ "`/loom templates create name <name> [description]`",
752
+ "",
753
+ "Example:",
754
+ "`/loom templates create name game_character RPG character memory`",
755
+ "",
756
+ "`/loom templates create help` for all wizard commands",
757
+ ].join("\n"),
758
+ };
759
+ }
760
+
761
+ case "name": {
762
+ if (!subArgs) {
763
+ return {
764
+ text: "Usage: `/loom templates create name <name> [description]`\nName: letters, digits, underscores, hyphens only",
765
+ };
766
+ }
767
+ const parts = subArgs.split(/\s+/);
768
+ const name = parts[0];
769
+ const desc = parts.slice(1).join(" ");
770
+ if (/[^a-zA-Z0-9_-]/.test(name)) {
771
+ return {
772
+ text: `❌ Invalid template name \`${name}\` — use letters, digits, underscores, hyphens only`,
773
+ };
774
+ }
775
+ if (!_wizardState) {
776
+ _wizardState = { name: "", description: "", domains: {}, createdAt: Date.now() };
777
+ }
778
+ _wizardState.name = name;
779
+ if (desc) _wizardState.description = desc;
780
+ return {
781
+ text: [
782
+ `✅ Template name: \`${name}\`${desc ? `\nDescription: ${desc}` : ""}`,
783
+ "",
784
+ "**Step 2:** add a domain (memory group)",
785
+ "`/loom templates create domain <domain> <field1|desc, field2|desc>`",
786
+ "",
787
+ "Examples:",
788
+ "`/loom templates create domain character name|name, class|class, level|level`",
789
+ "`/loom templates create domain story main_quest|main quest, achievements|achievements`",
790
+ ].join("\n"),
791
+ };
792
+ }
793
+
794
+ case "desc": {
795
+ if (!subArgs) {
796
+ return { text: "Usage: `/loom templates create desc <description>`" };
797
+ }
798
+ if (!_wizardState) {
799
+ _wizardState = { name: "", description: "", domains: {}, createdAt: Date.now() };
800
+ }
801
+ _wizardState.description = subArgs;
802
+ return { text: `✅ Description updated: ${subArgs}\n\n` + formatWizardStatus(_wizardState) };
803
+ }
804
+
805
+ case "domain":
806
+ case "add": {
807
+ if (!subArgs) {
808
+ return {
809
+ text: [
810
+ "Usage: `/loom templates create domain <domain> <field definitions>`",
811
+ "",
812
+ "**Field format:** `name|description` or `name` (comma-separated)",
813
+ "",
814
+ "Examples:",
815
+ "`/loom templates create domain identity name|name, age|age, location|location`",
816
+ "`/loom templates create domain preferences music|music, food|food`",
817
+ ].join("\n"),
818
+ };
819
+ }
820
+ const firstSpace = subArgs.indexOf(" ");
821
+ if (firstSpace === -1) {
822
+ return {
823
+ text: `❌ Add field definitions for the domain\nUsage: \`/loom templates create domain ${subArgs} field1|desc, field2|desc\``,
824
+ };
825
+ }
826
+ const domainName = subArgs.slice(0, firstSpace).trim();
827
+ const fieldsStr = subArgs.slice(firstSpace + 1).trim();
828
+
829
+ if (/[^a-zA-Z0-9_]/.test(domainName)) {
830
+ return {
831
+ text: `❌ Invalid domain name \`${domainName}\` — letters, digits, underscores only`,
832
+ };
833
+ }
834
+
835
+ const fields: Record<string, string> = {};
836
+ const fieldParts = fieldsStr.split(",").map((s) => s.trim()).filter(Boolean);
837
+ for (const part of fieldParts) {
838
+ const pipeIdx = part.indexOf("|");
839
+ if (pipeIdx === -1) {
840
+ const fieldName = part.trim().replace(/\s+/g, "_");
841
+ if (fieldName) fields[fieldName] = fieldName;
842
+ } else {
843
+ const fieldName = part.slice(0, pipeIdx).trim().replace(/\s+/g, "_");
844
+ const fieldDesc = part.slice(pipeIdx + 1).trim();
845
+ if (fieldName) fields[fieldName] = fieldDesc || fieldName;
846
+ }
847
+ }
848
+
849
+ if (Object.keys(fields).length === 0) {
850
+ return { text: "❌ Define at least one field\nFormat: `name|desc, name|desc`" };
851
+ }
852
+
853
+ if (!_wizardState) {
854
+ _wizardState = { name: "", description: "", domains: {}, createdAt: Date.now() };
855
+ }
856
+ _wizardState.domains[domainName] = { fields };
857
+
858
+ const fieldList = Object.entries(fields)
859
+ .map(([n, d]) => ` \`${n}\` — ${d}`)
860
+ .join("\n");
861
+ const domainCount = Object.keys(_wizardState.domains).length;
862
+ const canSave = _wizardState.name && domainCount > 0;
863
+
864
+ return {
865
+ text: [
866
+ `✅ Added domain \`${domainName}\` (${Object.keys(fields).length} field(s))`,
867
+ fieldList,
868
+ "",
869
+ `Total domains: ${domainCount}`,
870
+ "",
871
+ "Add more: `/loom templates create domain <name> <fields...>`",
872
+ "Remove: `/loom templates create remove <domain>`",
873
+ canSave
874
+ ? "**Save:** `/loom templates create done`"
875
+ : `⚠️ Set template name first: \`/loom templates create name <name>\``,
876
+ ].join("\n"),
877
+ };
878
+ }
879
+
880
+ case "remove":
881
+ case "rm": {
882
+ if (!subArgs) {
883
+ return { text: "Usage: `/loom templates create remove <domain>`" };
884
+ }
885
+ const w = getWizard();
886
+ if (!w) {
887
+ return { text: "❌ No wizard in progress. Start with `/loom templates create`" };
888
+ }
889
+ const target = subArgs.trim();
890
+ if (!w.domains[target]) {
891
+ const available = Object.keys(w.domains);
892
+ return {
893
+ text: `❌ Domain \`${target}\` not found\nDomains: ${available.length ? available.map((d) => `\`${d}\``).join(", ") : "(none)"}`,
894
+ };
895
+ }
896
+ delete w.domains[target];
897
+ return { text: `🗑️ Removed domain \`${target}\`\n\n` + formatWizardStatus(w) };
898
+ }
899
+
900
+ case "done":
901
+ case "save": {
902
+ const w = getWizard();
903
+ if (!w) {
904
+ return { text: "❌ No wizard in progress. Start with `/loom templates create`" };
905
+ }
906
+ if (!w.name) {
907
+ return { text: "❌ Template name not set\nRun: `/loom templates create name <name>`" };
908
+ }
909
+ if (Object.keys(w.domains).length === 0) {
910
+ return {
911
+ text: "❌ Add at least one domain\nRun: `/loom templates create domain <domain> <fields...>`",
912
+ };
913
+ }
914
+
915
+ const templateData = buildTemplateFromWizard(w);
916
+ try {
917
+ const result = await client.saveCustomTemplate(templateData);
918
+ _wizardState = null;
919
+ return {
920
+ text: [
921
+ `✅ Custom template saved: \`${result.name}\``,
922
+ `File: \`${result.path}\``,
923
+ `Domains: ${Object.keys(w.domains).length}`,
924
+ "",
925
+ `Preview: \`/loom templates show ${result.name}\``,
926
+ `Apply: \`/loom templates use ${result.name}\` on a new schema`,
927
+ ].join("\n"),
928
+ };
929
+ } catch (e) {
930
+ return { text: `❌ Failed to save template: ${e}` };
931
+ }
932
+ }
933
+
934
+ case "cancel": {
935
+ if (!getWizard()) {
936
+ return { text: "ℹ️ No template wizard in progress" };
937
+ }
938
+ _wizardState = null;
939
+ return { text: "🚫 Template creation cancelled" };
940
+ }
941
+
942
+ case "status": {
943
+ const w = getWizard();
944
+ if (!w) {
945
+ return { text: "ℹ️ No wizard in progress. Start with `/loom templates create`" };
946
+ }
947
+ return { text: formatWizardStatus(w) };
948
+ }
949
+
950
+ case "help": {
951
+ return { text: formatTemplateCreateHelp() };
952
+ }
953
+
954
+ default: {
955
+ return { text: `❌ Unknown wizard command: \`${sub}\`\n\n` + formatTemplateCreateHelp() };
956
+ }
957
+ }
958
+ }
959
+
960
+ function buildTemplateFromWizard(w: WizardState): Record<string, unknown> {
961
+ const result: Record<string, unknown> = {
962
+ _meta: { name: w.name, description: w.description || `Custom template: ${w.name}` },
963
+ };
964
+
965
+ for (const [domainName, domainData] of Object.entries(w.domains)) {
966
+ const data: Record<string, unknown> = {
967
+ meta: {
968
+ domain: { value: domainName, description: `Schema domain: ${domainName}` },
969
+ created_at: { value: "", description: "Creation timestamp" },
970
+ last_updated: { value: "", description: "Last update timestamp" },
971
+ mutable: { value: "true", description: "Whether CM can modify this domain" },
972
+ },
973
+ };
974
+ for (const [fn, fd] of Object.entries(domainData.fields)) {
975
+ data[fn] = { value: "", description: fd };
976
+ }
977
+ result[domainName] = { data, _mutable: true };
978
+ }
979
+
980
+ return result;
981
+ }
982
+
983
+ async function handleTemplateDelete(client: LoomClient, name: string) {
984
+ try {
985
+ await client.deleteCustomTemplate(name);
986
+ return { text: `🗑️ Custom template deleted: \`${name}\`` };
987
+ } catch (e) {
988
+ return {
989
+ text: `❌ Failed to delete template: ${e}\n⚠️ Only custom templates can be deleted (not built-in or shared)`,
990
+ };
991
+ }
992
+ }
993
+
994
+ // ---------------------------------------------------------------------------
995
+ // Template compact format parser
996
+ // ---------------------------------------------------------------------------
997
+
998
+ class TemplateParseError extends Error {
999
+ constructor(message: string) {
1000
+ super(message);
1001
+ this.name = "TemplateParseError";
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Parses the compact template format:
1007
+ *
1008
+ * name [optional description]
1009
+ * domain1: field1 | desc, field2 | desc
1010
+ * domain2: field3 | desc
1011
+ *
1012
+ * Lines starting with whitespace are domain definitions.
1013
+ * The first non-indented token is the template name; remaining text is description.
1014
+ */
1015
+ function parseCompactTemplate(input: string): Record<string, unknown> {
1016
+ const lines = input.split("\n").map((l) => l.trimEnd());
1017
+ if (!lines.length || !lines[0].trim()) {
1018
+ throw new TemplateParseError("Template name cannot be empty");
1019
+ }
1020
+
1021
+ const headerLine = lines[0].trim();
1022
+ const headerParts = headerLine.split(/\s+/);
1023
+ const name = headerParts[0];
1024
+ const description = headerParts.slice(1).join(" ");
1025
+
1026
+ if (!name || /[^a-zA-Z0-9_-]/.test(name)) {
1027
+ throw new TemplateParseError(
1028
+ `Invalid template name "${name}" — use letters, digits, underscores, hyphens only`,
1029
+ );
1030
+ }
1031
+
1032
+ const result: Record<string, unknown> = {
1033
+ _meta: { name, description: description || `Custom template: ${name}` },
1034
+ };
1035
+
1036
+ let domainCount = 0;
1037
+ for (let i = 1; i < lines.length; i++) {
1038
+ const line = lines[i];
1039
+ if (!line.trim()) continue;
1040
+
1041
+ const domainMatch = line.trim().match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
1042
+ if (!domainMatch) {
1043
+ throw new TemplateParseError(
1044
+ `Line ${i + 1} invalid: "${line.trim()}"\n` +
1045
+ `Expected: domain_name: field1 | desc, field2 | desc`,
1046
+ );
1047
+ }
1048
+
1049
+ const domainName = domainMatch[1];
1050
+ const fieldsStr = domainMatch[2];
1051
+
1052
+ const fields: Record<string, string> = {};
1053
+ const fieldParts = fieldsStr.split(",").map((s) => s.trim()).filter(Boolean);
1054
+ for (const part of fieldParts) {
1055
+ const pipeIdx = part.indexOf("|");
1056
+ if (pipeIdx === -1) {
1057
+ const fieldName = part.trim().replace(/\s+/g, "_");
1058
+ fields[fieldName] = fieldName;
1059
+ } else {
1060
+ const fieldName = part.slice(0, pipeIdx).trim().replace(/\s+/g, "_");
1061
+ const fieldDesc = part.slice(pipeIdx + 1).trim();
1062
+ if (!fieldName) {
1063
+ throw new TemplateParseError(`Line ${i + 1} empty field name: "${part}"`);
1064
+ }
1065
+ fields[fieldName] = fieldDesc || fieldName;
1066
+ }
1067
+ }
1068
+
1069
+ const domainData: Record<string, Record<string, string>> = {
1070
+ meta: {
1071
+ domain: domainName,
1072
+ created_at: "",
1073
+ last_updated: "",
1074
+ mutable: "true",
1075
+ },
1076
+ };
1077
+ for (const [fn, fd] of Object.entries(fields)) {
1078
+ domainData[fn] = { value: "", description: fd };
1079
+ }
1080
+
1081
+ const metaFormatted: Record<string, { value: string; description: string }> = {};
1082
+ metaFormatted["domain"] = { value: domainName, description: `Schema domain: ${domainName}` };
1083
+ metaFormatted["created_at"] = { value: "", description: "Creation timestamp" };
1084
+ metaFormatted["last_updated"] = { value: "", description: "Last update timestamp" };
1085
+ metaFormatted["mutable"] = { value: "true", description: "Whether CM can modify this domain" };
1086
+
1087
+ const data: Record<string, unknown> = { meta: metaFormatted };
1088
+ for (const [fn, fd] of Object.entries(fields)) {
1089
+ data[fn] = { value: "", description: fd };
1090
+ }
1091
+
1092
+ result[domainName] = { data, _mutable: true };
1093
+ domainCount++;
1094
+ }
1095
+
1096
+ if (domainCount === 0) {
1097
+ throw new TemplateParseError(
1098
+ "Define at least one domain\nFormat: domain_name: field1 | desc, field2 | desc",
1099
+ );
1100
+ }
1101
+
1102
+ return result;
1103
+ }
1104
+
1105
+ // ---------------------------------------------------------------------------
1106
+ // Help text formatters
1107
+ // ---------------------------------------------------------------------------
1108
+
1109
+ function formatTemplateHelp(): string {
1110
+ return [
1111
+ "📋 **Template commands**",
1112
+ "",
1113
+ "**List / preview**",
1114
+ "`/loom templates` — list templates (grouped by source)",
1115
+ "`/loom templates show <name>` — preview domains and fields",
1116
+ "",
1117
+ "**Apply**",
1118
+ "`/loom templates use <name> [schema]` — create schema from template and switch",
1119
+ "",
1120
+ "**Custom**",
1121
+ "`/loom templates create` — wizard for custom template",
1122
+ "`/loom templates delete <name>` — delete a custom template",
1123
+ "",
1124
+ "**Help**",
1125
+ "`/loom templates help` — this help",
1126
+ ].join("\n");
1127
+ }
1128
+
1129
+ function formatTemplateCreateHelp(): string {
1130
+ return [
1131
+ "✏️ **Create a custom template**",
1132
+ "",
1133
+ "**Wizard** (recommended)",
1134
+ "",
1135
+ "Small steps; no need to memorize the full format:",
1136
+ "",
1137
+ "1️⃣ `/loom templates create` — start",
1138
+ "2️⃣ `/loom templates create name <name> [description]`",
1139
+ "3️⃣ `/loom templates create domain <domain> <field1|desc, field2|desc>` — repeat as needed",
1140
+ "4️⃣ `/loom templates create done` — save",
1141
+ "",
1142
+ "**Example:**",
1143
+ "`/loom templates create name game_character RPG character`",
1144
+ "`/loom templates create domain character name|name, class|class, level|level`",
1145
+ "`/loom templates create domain story main_quest|main quest, achievements|achievements`",
1146
+ "`/loom templates create done`",
1147
+ "",
1148
+ "**Other wizard commands:**",
1149
+ "`/loom templates create desc <text>` — set description",
1150
+ "`/loom templates create remove <domain>` — remove a domain",
1151
+ "`/loom templates create status` — progress",
1152
+ "`/loom templates create cancel` — abort",
1153
+ "",
1154
+ "---",
1155
+ "",
1156
+ "**One-shot** (advanced)",
1157
+ "",
1158
+ "```",
1159
+ "/loom templates create my_template My description",
1160
+ " domain1: field1 | desc, field2 | desc",
1161
+ " domain2: field3 | desc",
1162
+ "```",
1163
+ ].join("\n");
1164
+ }