@cryptiklemur/lattice 1.1.0 → 1.2.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.
@@ -0,0 +1,471 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Plus, Trash2, Pencil, X, Loader2, Brain } from "lucide-react";
3
+ import Markdown from "react-markdown";
4
+ import { useWebSocket } from "../../hooks/useWebSocket";
5
+ import type { ServerMessage } from "@lattice/shared";
6
+
7
+ interface MemoryEntry {
8
+ filename: string;
9
+ name: string;
10
+ description: string;
11
+ type: string;
12
+ }
13
+
14
+ interface ProjectMemoryProps {
15
+ projectSlug?: string;
16
+ }
17
+
18
+ var MEMORY_TYPES = ["user", "feedback", "project", "reference"];
19
+
20
+ function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } {
21
+ var match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
22
+ if (!match) return { meta: {}, body: content };
23
+ var meta: Record<string, string> = {};
24
+ var lines = match[1].split(/\r?\n/);
25
+ for (var i = 0; i < lines.length; i++) {
26
+ var colonIdx = lines[i].indexOf(":");
27
+ if (colonIdx > 0) {
28
+ var key = lines[i].slice(0, colonIdx).trim();
29
+ var value = lines[i].slice(colonIdx + 1).trim().replace(/^["']|["']$/g, "");
30
+ meta[key] = value;
31
+ }
32
+ }
33
+ return { meta, body: match[2] };
34
+ }
35
+
36
+ function slugify(name: string): string {
37
+ return name
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/g, "_")
40
+ .replace(/^_+|_+$/g, "")
41
+ || "memory";
42
+ }
43
+
44
+ function buildContent(name: string, description: string, type: string, body: string): string {
45
+ return "---\nname: " + name + "\ndescription: " + description + "\ntype: " + type + "\n---\n" + body;
46
+ }
47
+
48
+ function MemoryCard({
49
+ memory,
50
+ onClick,
51
+ onDelete,
52
+ }: {
53
+ memory: MemoryEntry;
54
+ onClick: () => void;
55
+ onDelete: () => void;
56
+ }) {
57
+ var [confirmDelete, setConfirmDelete] = useState(false);
58
+
59
+ return (
60
+ <div
61
+ onClick={onClick}
62
+ className="flex items-start gap-3 px-3 py-2.5 bg-base-300 border border-base-content/15 rounded-xl transition-colors duration-[120ms] cursor-pointer hover:border-base-content/30 hover:bg-base-300/80"
63
+ role="button"
64
+ tabIndex={0}
65
+ onKeyDown={function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }}
66
+ >
67
+ <Brain size={14} className="text-base-content/25 mt-0.5 flex-shrink-0" />
68
+ <div className="flex-1 min-w-0">
69
+ <div className="flex items-center gap-2">
70
+ <span className="text-[13px] font-bold text-base-content truncate">{memory.name || memory.filename}</span>
71
+ <span className="shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded-md bg-base-content/8 text-base-content/40">
72
+ {memory.type}
73
+ </span>
74
+ </div>
75
+ {memory.description && (
76
+ <div className="text-[12px] text-base-content/40 mt-0.5 line-clamp-2">{memory.description}</div>
77
+ )}
78
+ </div>
79
+ <div className="flex gap-1 flex-shrink-0 mt-0.5" onClick={function (e) { e.stopPropagation(); }}>
80
+ {confirmDelete ? (
81
+ <div className="flex gap-1">
82
+ <button
83
+ onClick={function () { onDelete(); setConfirmDelete(false); }}
84
+ className="btn btn-error btn-xs"
85
+ >
86
+ Delete
87
+ </button>
88
+ <button
89
+ onClick={function () { setConfirmDelete(false); }}
90
+ className="btn btn-ghost btn-xs"
91
+ >
92
+ Cancel
93
+ </button>
94
+ </div>
95
+ ) : (
96
+ <button
97
+ onClick={function () { setConfirmDelete(true); }}
98
+ aria-label={"Delete " + memory.name}
99
+ className="btn btn-ghost btn-xs btn-square text-base-content/30 hover:text-error focus-visible:ring-2 focus-visible:ring-primary"
100
+ >
101
+ <Trash2 size={12} />
102
+ </button>
103
+ )}
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ function MemoryViewModal({
110
+ memory,
111
+ content,
112
+ onClose,
113
+ onEdit,
114
+ }: {
115
+ memory: MemoryEntry;
116
+ content: string;
117
+ onClose: () => void;
118
+ onEdit: () => void;
119
+ }) {
120
+ var parsed = parseFrontmatter(content);
121
+ var hasMeta = Object.keys(parsed.meta).length > 0;
122
+
123
+ return (
124
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center">
125
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} />
126
+ <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden">
127
+ <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
128
+ <div className="flex items-center gap-2 min-w-0">
129
+ <Brain size={16} className="text-primary flex-shrink-0" />
130
+ <h2 className="text-[15px] font-mono font-bold text-base-content truncate">
131
+ {parsed.meta.name || memory.filename}
132
+ </h2>
133
+ </div>
134
+ <div className="flex items-center gap-1">
135
+ <button
136
+ onClick={onEdit}
137
+ className="btn btn-ghost btn-xs gap-1.5 text-base-content/50 hover:text-base-content"
138
+ >
139
+ <Pencil size={12} />
140
+ Edit
141
+ </button>
142
+ <button
143
+ onClick={onClose}
144
+ aria-label="Close"
145
+ className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
146
+ >
147
+ <X size={16} />
148
+ </button>
149
+ </div>
150
+ </div>
151
+
152
+ <div className="overflow-y-auto flex-1">
153
+ {hasMeta && (
154
+ <div className="px-5 py-3 border-b border-base-content/10 bg-base-300/30">
155
+ <div className="flex flex-wrap gap-x-6 gap-y-1.5">
156
+ {Object.entries(parsed.meta).map(function ([key, value]) {
157
+ return (
158
+ <div key={key} className="flex items-baseline gap-1.5">
159
+ <span className="text-[11px] font-mono text-base-content/35 uppercase tracking-wider">{key}</span>
160
+ <span className="text-[12px] text-base-content/70">{value}</span>
161
+ </div>
162
+ );
163
+ })}
164
+ </div>
165
+ </div>
166
+ )}
167
+
168
+ <div className="px-5 py-4">
169
+ <div className="prose prose-sm max-w-none prose-headings:text-base-content prose-headings:font-mono prose-p:text-base-content/70 prose-strong:text-base-content prose-code:text-base-content/60 prose-code:bg-base-100/50 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-[11px] prose-pre:bg-base-100 prose-pre:text-base-content/70 prose-pre:text-[11px] prose-a:text-primary prose-li:text-base-content/70 prose-li:marker:text-base-content/30 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
170
+ <Markdown>{parsed.body}</Markdown>
171
+ </div>
172
+ </div>
173
+ </div>
174
+
175
+ <div className="px-5 py-3 border-t border-base-content/15 flex-shrink-0">
176
+ <div className="text-[11px] font-mono text-base-content/30 truncate">{memory.filename}</div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function MemoryEditModal({
184
+ memory,
185
+ initialContent,
186
+ onClose,
187
+ onSave,
188
+ isSaving,
189
+ }: {
190
+ memory: MemoryEntry | null;
191
+ initialContent: string;
192
+ onClose: () => void;
193
+ onSave: (filename: string, content: string) => void;
194
+ isSaving: boolean;
195
+ }) {
196
+ var isNew = memory === null;
197
+ var parsed = parseFrontmatter(initialContent);
198
+
199
+ var [name, setName] = useState(parsed.meta.name || (memory ? memory.name : ""));
200
+ var [description, setDescription] = useState(parsed.meta.description || (memory ? memory.description : ""));
201
+ var [type, setType] = useState(parsed.meta.type || (memory ? memory.type : "project"));
202
+ var [body, setBody] = useState(parsed.body);
203
+
204
+ function handleSave() {
205
+ var content = buildContent(name, description, type, body);
206
+ var filename = isNew ? slugify(name) + ".md" : memory!.filename;
207
+ onSave(filename, content);
208
+ }
209
+
210
+ return (
211
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center">
212
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} />
213
+ <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col overflow-hidden">
214
+ <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
215
+ <div className="flex items-center gap-2">
216
+ <Brain size={16} className="text-primary flex-shrink-0" />
217
+ <h2 className="text-[15px] font-mono font-bold text-base-content">
218
+ {isNew ? "New Memory" : "Edit Memory"}
219
+ </h2>
220
+ </div>
221
+ <button
222
+ onClick={onClose}
223
+ aria-label="Close"
224
+ className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
225
+ >
226
+ <X size={16} />
227
+ </button>
228
+ </div>
229
+
230
+ <div className="overflow-y-auto flex-1 px-5 py-4 space-y-4">
231
+ <div className="grid grid-cols-2 gap-3">
232
+ <div className="col-span-2">
233
+ <label className="text-[11px] font-mono text-base-content/40 uppercase tracking-wider mb-1.5 block">
234
+ Name
235
+ </label>
236
+ <input
237
+ type="text"
238
+ value={name}
239
+ onChange={function (e) { setName(e.target.value); }}
240
+ className="input input-sm w-full bg-base-300 border-base-content/15 text-[13px]"
241
+ placeholder="Memory name"
242
+ />
243
+ </div>
244
+
245
+ <div className="col-span-2">
246
+ <label className="text-[11px] font-mono text-base-content/40 uppercase tracking-wider mb-1.5 block">
247
+ Description
248
+ </label>
249
+ <input
250
+ type="text"
251
+ value={description}
252
+ onChange={function (e) { setDescription(e.target.value); }}
253
+ className="input input-sm w-full bg-base-300 border-base-content/15 text-[13px]"
254
+ placeholder="Brief description"
255
+ />
256
+ </div>
257
+
258
+ <div>
259
+ <label className="text-[11px] font-mono text-base-content/40 uppercase tracking-wider mb-1.5 block">
260
+ Type
261
+ </label>
262
+ <select
263
+ value={type}
264
+ onChange={function (e) { setType(e.target.value); }}
265
+ className="select select-sm w-full bg-base-300 border-base-content/15 text-[13px]"
266
+ >
267
+ {MEMORY_TYPES.map(function (t) {
268
+ return <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>;
269
+ })}
270
+ </select>
271
+ </div>
272
+
273
+ {!isNew && (
274
+ <div>
275
+ <label className="text-[11px] font-mono text-base-content/40 uppercase tracking-wider mb-1.5 block">
276
+ Filename
277
+ </label>
278
+ <input
279
+ type="text"
280
+ value={memory!.filename}
281
+ readOnly
282
+ className="input input-sm w-full bg-base-100/50 border-base-content/10 text-[13px] text-base-content/40 cursor-not-allowed"
283
+ />
284
+ </div>
285
+ )}
286
+ </div>
287
+
288
+ <div>
289
+ <label className="text-[11px] font-mono text-base-content/40 uppercase tracking-wider mb-1.5 block">
290
+ Content
291
+ </label>
292
+ <textarea
293
+ value={body}
294
+ onChange={function (e) { setBody(e.target.value); }}
295
+ className="textarea w-full bg-base-300 border-base-content/15 text-[13px] font-mono resize-none min-h-[200px]"
296
+ placeholder="Memory content (markdown supported)"
297
+ />
298
+ </div>
299
+ </div>
300
+
301
+ <div className="px-5 py-3 border-t border-base-content/15 flex items-center justify-end gap-2 flex-shrink-0">
302
+ <button onClick={onClose} className="btn btn-ghost btn-sm">Cancel</button>
303
+ <button
304
+ onClick={handleSave}
305
+ disabled={isSaving || !name.trim()}
306
+ className="btn btn-primary btn-sm gap-1.5"
307
+ >
308
+ {isSaving && <Loader2 size={12} className="animate-spin" />}
309
+ {isNew ? "Create" : "Save"}
310
+ </button>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ export function ProjectMemory({ projectSlug }: ProjectMemoryProps) {
318
+ var { send, subscribe, unsubscribe } = useWebSocket();
319
+ var [memories, setMemories] = useState<MemoryEntry[]>([]);
320
+ var [loading, setLoading] = useState(false);
321
+ var [viewState, setViewState] = useState<{ memory: MemoryEntry; content: string } | null>(null);
322
+ var [editState, setEditState] = useState<{ memory: MemoryEntry | null; content: string } | null>(null);
323
+ var [isSaving, setIsSaving] = useState(false);
324
+ var [pendingView, setPendingView] = useState<MemoryEntry | null>(null);
325
+
326
+ useEffect(function () {
327
+ function handleListResult(msg: ServerMessage) {
328
+ if (msg.type !== "memory:list_result") return;
329
+ var data = msg as { type: "memory:list_result"; memories: MemoryEntry[] };
330
+ setMemories(data.memories);
331
+ setLoading(false);
332
+ }
333
+
334
+ function handleViewResult(msg: ServerMessage) {
335
+ if (msg.type !== "memory:view_result") return;
336
+ var data = msg as { type: "memory:view_result"; content: string };
337
+ setPendingView(function (prev) {
338
+ if (prev) {
339
+ setViewState({ memory: prev, content: data.content });
340
+ }
341
+ return null;
342
+ });
343
+ }
344
+
345
+ function handleSaveResult(msg: ServerMessage) {
346
+ if (msg.type !== "memory:save_result") return;
347
+ setIsSaving(false);
348
+ setEditState(null);
349
+ setViewState(null);
350
+ }
351
+
352
+ function handleDeleteResult(msg: ServerMessage) {
353
+ if (msg.type !== "memory:delete_result") return;
354
+ }
355
+
356
+ subscribe("memory:list_result", handleListResult);
357
+ subscribe("memory:view_result", handleViewResult);
358
+ subscribe("memory:save_result", handleSaveResult);
359
+ subscribe("memory:delete_result", handleDeleteResult);
360
+
361
+ return function () {
362
+ unsubscribe("memory:list_result", handleListResult);
363
+ unsubscribe("memory:view_result", handleViewResult);
364
+ unsubscribe("memory:save_result", handleSaveResult);
365
+ unsubscribe("memory:delete_result", handleDeleteResult);
366
+ };
367
+ }, []);
368
+
369
+ useEffect(function () {
370
+ if (!projectSlug) return;
371
+ setLoading(true);
372
+ send({ type: "memory:list", projectSlug } as any);
373
+ }, [projectSlug]);
374
+
375
+ function handleView(memory: MemoryEntry) {
376
+ setPendingView(memory);
377
+ send({ type: "memory:view", projectSlug, filename: memory.filename } as any);
378
+ }
379
+
380
+ function handleDelete(memory: MemoryEntry) {
381
+ send({ type: "memory:delete", projectSlug, filename: memory.filename } as any);
382
+ }
383
+
384
+ function handleSave(filename: string, content: string) {
385
+ setIsSaving(true);
386
+ send({ type: "memory:save", projectSlug, filename, content } as any);
387
+ }
388
+
389
+ function handleEditFromView() {
390
+ if (!viewState) return;
391
+ setEditState({ memory: viewState.memory, content: viewState.content });
392
+ setViewState(null);
393
+ }
394
+
395
+ var grouped: Record<string, MemoryEntry[]> = {};
396
+ for (var i = 0; i < memories.length; i++) {
397
+ var m = memories[i];
398
+ var t = m.type || "other";
399
+ if (!grouped[t]) grouped[t] = [];
400
+ grouped[t].push(m);
401
+ }
402
+
403
+ var groupKeys = Object.keys(grouped);
404
+
405
+ return (
406
+ <div className="py-2 space-y-6">
407
+ <div className="flex items-center justify-end">
408
+ <button
409
+ onClick={function () { setEditState({ memory: null, content: "" }); }}
410
+ className="btn btn-primary btn-sm gap-1.5"
411
+ >
412
+ <Plus size={13} />
413
+ New Memory
414
+ </button>
415
+ </div>
416
+
417
+ {loading && (
418
+ <div className="flex items-center justify-center py-12">
419
+ <Loader2 size={18} className="animate-spin text-base-content/30" />
420
+ </div>
421
+ )}
422
+
423
+ {!loading && memories.length === 0 && (
424
+ <div className="py-12 text-center text-[13px] text-base-content/40">
425
+ No memories found.
426
+ </div>
427
+ )}
428
+
429
+ {!loading && groupKeys.map(function (groupKey) {
430
+ return (
431
+ <div key={groupKey}>
432
+ <div className="text-[12px] font-semibold text-base-content/40 mb-2">
433
+ {groupKey.charAt(0).toUpperCase() + groupKey.slice(1)}
434
+ </div>
435
+ <div className="space-y-2">
436
+ {grouped[groupKey].map(function (memory) {
437
+ return (
438
+ <MemoryCard
439
+ key={memory.filename}
440
+ memory={memory}
441
+ onClick={function () { handleView(memory); }}
442
+ onDelete={function () { handleDelete(memory); }}
443
+ />
444
+ );
445
+ })}
446
+ </div>
447
+ </div>
448
+ );
449
+ })}
450
+
451
+ {viewState && (
452
+ <MemoryViewModal
453
+ memory={viewState.memory}
454
+ content={viewState.content}
455
+ onClose={function () { setViewState(null); }}
456
+ onEdit={handleEditFromView}
457
+ />
458
+ )}
459
+
460
+ {editState !== null && (
461
+ <MemoryEditModal
462
+ memory={editState.memory}
463
+ initialContent={editState.content}
464
+ onClose={function () { setEditState(null); }}
465
+ onSave={handleSave}
466
+ isSaving={isSaving}
467
+ />
468
+ )}
469
+ </div>
470
+ );
471
+ }
@@ -10,6 +10,7 @@ import { ProjectPermissions } from "./ProjectPermissions";
10
10
  import { ProjectSkills } from "./ProjectSkills";
11
11
  import { ProjectRules } from "./ProjectRules";
12
12
  import { ProjectMcp } from "./ProjectMcp";
13
+ import { ProjectMemory } from "./ProjectMemory";
13
14
 
14
15
  var SECTION_CONFIG: Record<string, { title: string }> = {
15
16
  general: { title: "General" },
@@ -19,6 +20,7 @@ var SECTION_CONFIG: Record<string, { title: string }> = {
19
20
  skills: { title: "Skills" },
20
21
  rules: { title: "Rules" },
21
22
  permissions: { title: "Permissions" },
23
+ memory: { title: "Memory" },
22
24
  };
23
25
 
24
26
  function renderSection(
@@ -55,6 +57,10 @@ function renderSection(
55
57
  return <ProjectMcp settings={settings} updateSection={updateSection} />;
56
58
  }
57
59
 
60
+ if (section === "memory") {
61
+ return <ProjectMemory projectSlug={projectSlug} />;
62
+ }
63
+
58
64
  return (
59
65
  <div className="py-2 text-[13px] text-base-content/40">
60
66
  {section} section coming soon.