loki-mode 6.79.0 → 6.80.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,453 @@
1
+ """Test scaffold generator for Magic components.
2
+
3
+ Generates Vitest tests for React components and Playwright tests for
4
+ Web Components. Uses heuristics from the spec (detected props, behaviors,
5
+ accessibility requirements) to scaffold meaningful assertions, not just
6
+ smoke tests.
7
+ """
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ class TestGenerator:
15
+ """Generate test scaffolds for Magic-generated components.
16
+
17
+ The generator consumes a component's source code together with its
18
+ spec (typically a markdown doc) and produces unit tests (Vitest for
19
+ React) or end-to-end tests (Playwright for Web Components) with
20
+ assertions that go beyond smoke tests. A Storybook-compatible
21
+ snapshot story can also be produced for Chromatic integration.
22
+ """
23
+
24
+ def __init__(self, project_dir: str = "."):
25
+ self.project_dir = Path(project_dir)
26
+
27
+ # ----- Public API --------------------------------------------------
28
+
29
+ def generate_react_test(
30
+ self, component_code: str, component_name: str, spec: str
31
+ ) -> str:
32
+ """Generate a Vitest + React Testing Library test file.
33
+
34
+ Included assertions:
35
+ - Component renders without crashing
36
+ - Each prop is exercised
37
+ - Accessibility: has expected ARIA attributes
38
+ - Keyboard interaction (if spec mentions it)
39
+ - Snapshot test
40
+ """
41
+ safe_name = self._safe_name(component_name)
42
+ props = self._extract_props(spec)
43
+ a11y_reqs = self._extract_a11y(spec)
44
+ keyboard = self._has_keyboard_interaction(spec)
45
+
46
+ default_props = self._default_props(props)
47
+
48
+ lines = []
49
+ lines.append('import { describe, it, expect } from "vitest";')
50
+ lines.append('import { render, screen } from "@testing-library/react";')
51
+ if keyboard:
52
+ lines.append('import { fireEvent } from "@testing-library/react";')
53
+ lines.append(f'import {{ {safe_name} }} from "./{safe_name}";')
54
+ lines.append("")
55
+ lines.append(f'describe("{safe_name}", () => {{')
56
+ lines.append(' it("renders without crashing", () => {')
57
+ lines.append(
58
+ f" render(<{safe_name}{(' ' + default_props) if default_props else ''} />);"
59
+ )
60
+ lines.append(
61
+ f' expect(screen.getByTestId("{safe_name.lower()}")).toBeDefined();'
62
+ )
63
+ lines.append(" });")
64
+ lines.append("")
65
+
66
+ for prop in props:
67
+ lines.append(self._prop_test(safe_name, prop))
68
+
69
+ for req in a11y_reqs:
70
+ lines.append(self._a11y_test(safe_name, req))
71
+
72
+ if keyboard:
73
+ lines.append(self._keyboard_test(safe_name, default_props))
74
+
75
+ lines.append(self._snapshot_test(safe_name, default_props))
76
+ lines.append("});")
77
+ lines.append("")
78
+ return "\n".join(lines)
79
+
80
+ def generate_webcomponent_test(
81
+ self, component_code: str, component_name: str, spec: str
82
+ ) -> str:
83
+ """Generate a Playwright test for a Web Component.
84
+
85
+ Covers:
86
+ - Component mounts in a page and shadow root renders
87
+ - Attributes (derived from detected props) reflect to state
88
+ - Accessibility requirements mentioned in spec
89
+ """
90
+ safe_name = self._safe_name(component_name)
91
+ tag = self._kebab_case(safe_name)
92
+ props = self._extract_props(spec)
93
+ a11y_reqs = self._extract_a11y(spec)
94
+
95
+ lines = []
96
+ lines.append('import { test, expect } from "@playwright/test";')
97
+ lines.append("")
98
+ lines.append(f'const FIXTURE_URL = "/fixtures/{tag}.html";')
99
+ lines.append("")
100
+ lines.append(f'test.describe("{safe_name} web component", () => {{')
101
+ lines.append(' test("mounts and exposes shadow DOM", async ({ page }) => {')
102
+ lines.append(" await page.goto(FIXTURE_URL);")
103
+ lines.append(f' const host = page.locator("{tag}");')
104
+ lines.append(" await expect(host).toBeVisible();")
105
+ lines.append(
106
+ " const hasShadow = await host.evaluate("
107
+ "(el) => !!el.shadowRoot);"
108
+ )
109
+ lines.append(" expect(hasShadow).toBe(true);")
110
+ lines.append(" });")
111
+ lines.append("")
112
+
113
+ for prop in props:
114
+ attr = self._kebab_case(prop["name"])
115
+ lines.append(
116
+ f' test("reflects attribute {attr}", async ({{ page }}) => {{'
117
+ )
118
+ lines.append(" await page.goto(FIXTURE_URL);")
119
+ lines.append(f' const host = page.locator("{tag}");')
120
+ lines.append(
121
+ f' await host.evaluate((el, v) => el.setAttribute("{attr}", v), '
122
+ f'"{self._sample_value(prop)}");'
123
+ )
124
+ lines.append(
125
+ f' const value = await host.getAttribute("{attr}");'
126
+ )
127
+ lines.append(
128
+ f' expect(value).toBe("{self._sample_value(prop)}");'
129
+ )
130
+ lines.append(" });")
131
+ lines.append("")
132
+
133
+ for req in a11y_reqs:
134
+ slug = re.sub(r"\W+", "-", req.lower()).strip("-") or "a11y"
135
+ lines.append(
136
+ f' test("a11y: {req}", async ({{ page }}) => {{'
137
+ )
138
+ lines.append(" await page.goto(FIXTURE_URL);")
139
+ lines.append(f' const host = page.locator("{tag}");')
140
+ lines.append(" const role = await host.getAttribute(\"role\");")
141
+ lines.append(" const ariaLabel = await host.getAttribute(\"aria-label\");")
142
+ lines.append(
143
+ " expect(role || ariaLabel).toBeTruthy();"
144
+ )
145
+ lines.append(f' // requirement: {req}')
146
+ lines.append(f' // tag: {slug}')
147
+ lines.append(" });")
148
+ lines.append("")
149
+
150
+ lines.append("});")
151
+ lines.append("")
152
+ return "\n".join(lines)
153
+
154
+ def generate_snapshot(self, component_name: str) -> str:
155
+ """Generate a Storybook story (also usable as Chromatic snapshot)."""
156
+ safe_name = self._safe_name(component_name)
157
+ lines = []
158
+ lines.append(f'import type {{ Meta, StoryObj }} from "@storybook/react";')
159
+ lines.append(f'import {{ {safe_name} }} from "./{safe_name}";')
160
+ lines.append("")
161
+ lines.append(f'const meta: Meta<typeof {safe_name}> = {{')
162
+ lines.append(f' title: "Magic/{safe_name}",')
163
+ lines.append(f' component: {safe_name},')
164
+ lines.append(' parameters: {')
165
+ lines.append(' chromatic: { viewports: [320, 768, 1280] },')
166
+ lines.append(' },')
167
+ lines.append("};")
168
+ lines.append("")
169
+ lines.append("export default meta;")
170
+ lines.append(f'type Story = StoryObj<typeof {safe_name}>;')
171
+ lines.append("")
172
+ lines.append("export const Default: Story = {")
173
+ lines.append(" args: {},")
174
+ lines.append("};")
175
+ lines.append("")
176
+ return "\n".join(lines)
177
+
178
+ # ----- Spec parsing helpers ---------------------------------------
179
+
180
+ def _extract_props(self, spec: str) -> list:
181
+ """Parse `## Props` section of spec to get prop names and types.
182
+
183
+ Supports a few common markdown styles:
184
+ - ``- name (type): description``
185
+ - ``| name | type | description |`` (markdown table rows)
186
+ - ``* name - type - description``
187
+ Returns a list of dicts: {name, type, description, required}.
188
+ """
189
+ if not spec:
190
+ return []
191
+ section = self._extract_section(spec, "Props")
192
+ if not section:
193
+ return []
194
+
195
+ props: list = []
196
+ seen: set = set()
197
+
198
+ # Dash/bullet lines: - name (type[, required]): description
199
+ bullet_re = re.compile(
200
+ r"^\s*[-*]\s+`?(?P<name>[A-Za-z_][\w-]*)`?"
201
+ r"(?:\s*\((?P<type>[^)]+)\))?"
202
+ r"\s*[:\-]?\s*(?P<desc>.*)$"
203
+ )
204
+ # Table rows: | name | type | desc |
205
+ table_re = re.compile(
206
+ r"^\s*\|\s*`?(?P<name>[A-Za-z_][\w-]*)`?\s*\|"
207
+ r"\s*(?P<type>[^|]+)\|"
208
+ r"\s*(?P<desc>[^|]*)\|"
209
+ )
210
+
211
+ for raw_line in section.splitlines():
212
+ line = raw_line.rstrip()
213
+ if not line.strip():
214
+ continue
215
+ # Skip markdown table header/separator rows
216
+ if re.match(r"^\s*\|?\s*-{3,}", line):
217
+ continue
218
+ if re.match(r"^\s*\|\s*name\s*\|", line, flags=re.IGNORECASE):
219
+ continue
220
+
221
+ m = table_re.match(line)
222
+ if not m:
223
+ m = bullet_re.match(line)
224
+ if not m:
225
+ continue
226
+
227
+ name = m.group("name").strip()
228
+ if not name or name.lower() == "name":
229
+ continue
230
+ if name in seen:
231
+ continue
232
+ seen.add(name)
233
+
234
+ raw_type = (m.groupdict().get("type") or "string").strip()
235
+ required = False
236
+ lower_type = raw_type.lower()
237
+ if "required" in lower_type:
238
+ required = True
239
+ raw_type = re.sub(
240
+ r",?\s*required", "", raw_type, flags=re.IGNORECASE
241
+ ).strip() or "string"
242
+
243
+ desc = (m.groupdict().get("desc") or "").strip().strip("|")
244
+
245
+ props.append(
246
+ {
247
+ "name": name,
248
+ "type": raw_type or "string",
249
+ "description": desc,
250
+ "required": required,
251
+ }
252
+ )
253
+ return props
254
+
255
+ def _extract_a11y(self, spec: str) -> list:
256
+ """Parse `## Accessibility` section.
257
+
258
+ Returns a flat list of strings, each describing one requirement.
259
+ """
260
+ if not spec:
261
+ return []
262
+ section = self._extract_section(spec, "Accessibility")
263
+ if not section:
264
+ return []
265
+
266
+ reqs: list = []
267
+ for raw_line in section.splitlines():
268
+ line = raw_line.strip()
269
+ if not line:
270
+ continue
271
+ if line.startswith(("-", "*")):
272
+ reqs.append(line[1:].strip())
273
+ elif re.match(r"^\d+\.\s+", line):
274
+ reqs.append(re.sub(r"^\d+\.\s+", "", line))
275
+ return reqs
276
+
277
+ def _extract_section(self, spec: str, heading: str) -> Optional[str]:
278
+ """Return the body of a markdown ``## <heading>`` section."""
279
+ pattern = re.compile(
280
+ r"^#{1,6}\s+" + re.escape(heading) + r"\s*$",
281
+ flags=re.IGNORECASE | re.MULTILINE,
282
+ )
283
+ match = pattern.search(spec)
284
+ if not match:
285
+ return None
286
+ start = match.end()
287
+ # Next heading of same or higher level
288
+ next_heading = re.compile(r"^#{1,6}\s+\S", flags=re.MULTILINE)
289
+ tail = spec[start:]
290
+ next_match = next_heading.search(tail)
291
+ body = tail[: next_match.start()] if next_match else tail
292
+ return body.strip()
293
+
294
+ def _has_keyboard_interaction(self, spec: str) -> bool:
295
+ if not spec:
296
+ return False
297
+ patterns = [
298
+ r"\bkeyboard\b",
299
+ r"\bkeypress\b",
300
+ r"\bkeydown\b",
301
+ r"\bEnter key\b",
302
+ r"\bSpace key\b",
303
+ r"\btab\s+order\b",
304
+ ]
305
+ return any(re.search(p, spec, flags=re.IGNORECASE) for p in patterns)
306
+
307
+ # ----- JSX/test rendering helpers ---------------------------------
308
+
309
+ def _default_props(self, props: list) -> str:
310
+ """Produce default JSX props string: prop1="value" prop2={true}"""
311
+ pieces: list = []
312
+ for prop in props:
313
+ pieces.append(self._jsx_prop(prop))
314
+ return " ".join(pieces)
315
+
316
+ def _jsx_prop(self, prop: dict) -> str:
317
+ name = prop["name"]
318
+ ptype = (prop.get("type") or "string").lower()
319
+ if "bool" in ptype:
320
+ return f"{name}={{true}}"
321
+ if "number" in ptype or "int" in ptype or "float" in ptype:
322
+ return f"{name}={{0}}"
323
+ if "func" in ptype or "=>" in ptype or "callback" in ptype:
324
+ return f"{name}={{() => {{}}}}"
325
+ if "array" in ptype or "[]" in ptype:
326
+ return f"{name}={{[]}}"
327
+ if "object" in ptype or "{" in ptype:
328
+ return f"{name}={{{{}}}}"
329
+ # default: string
330
+ return f'{name}="{self._sample_value(prop)}"'
331
+
332
+ def _sample_value(self, prop: dict) -> str:
333
+ ptype = (prop.get("type") or "string").lower()
334
+ if "bool" in ptype:
335
+ return "true"
336
+ if "number" in ptype or "int" in ptype or "float" in ptype:
337
+ return "1"
338
+ return f"sample-{prop['name']}"
339
+
340
+ def _prop_test(self, name: str, prop: dict) -> str:
341
+ prop_name = prop["name"]
342
+ ptype = (prop.get("type") or "string").lower()
343
+ default_jsx = self._jsx_prop(prop)
344
+ lines = []
345
+ lines.append(f' it("accepts prop {prop_name}", () => {{')
346
+ lines.append(f" render(<{name} {default_jsx} />);")
347
+ if "bool" in ptype:
348
+ lines.append(
349
+ f' const el = screen.getByTestId("{name.lower()}");'
350
+ )
351
+ lines.append(" expect(el).toBeDefined();")
352
+ elif "func" in ptype or "=>" in ptype or "callback" in ptype:
353
+ lines.append(
354
+ f' const el = screen.getByTestId("{name.lower()}");'
355
+ )
356
+ lines.append(" expect(el).toBeDefined();")
357
+ else:
358
+ sample = self._sample_value(prop)
359
+ lines.append(
360
+ f' const el = screen.getByTestId("{name.lower()}");'
361
+ )
362
+ lines.append(" expect(el).toBeDefined();")
363
+ lines.append(f' // prop {prop_name} sample value: {sample}')
364
+ lines.append(" });")
365
+ lines.append("")
366
+ return "\n".join(lines)
367
+
368
+ def _a11y_test(self, name: str, req: str) -> str:
369
+ lower = req.lower()
370
+ slug = re.sub(r"\W+", " ", req).strip().replace(" ", "-")[:60] or "a11y"
371
+ lines = []
372
+ lines.append(f' it("a11y: {req}", () => {{')
373
+ lines.append(f" render(<{name} />);")
374
+ lines.append(
375
+ f' const el = screen.getByTestId("{name.lower()}");'
376
+ )
377
+ if "role=" in lower or "role " in lower:
378
+ # e.g. "role=button"
379
+ m = re.search(r"role[=\s]+['\"]?([\w-]+)", lower)
380
+ role = m.group(1) if m else "presentation"
381
+ lines.append(
382
+ f' expect(el.getAttribute("role") || "").toMatch(/{role}/i);'
383
+ )
384
+ elif "aria-label" in lower:
385
+ lines.append(
386
+ ' expect(el.getAttribute("aria-label")).not.toBeNull();'
387
+ )
388
+ elif "aria-" in lower:
389
+ m = re.search(r"aria-([\w-]+)", lower)
390
+ attr = f"aria-{m.group(1)}" if m else "aria-label"
391
+ lines.append(
392
+ f' expect(el.getAttribute("{attr}")).not.toBeNull();'
393
+ )
394
+ elif "focus" in lower:
395
+ lines.append(" el.focus();")
396
+ lines.append(" expect(document.activeElement).toBe(el);")
397
+ else:
398
+ lines.append(" expect(el).toBeDefined();")
399
+ lines.append(f' // requirement slug: {slug}')
400
+ lines.append(" });")
401
+ lines.append("")
402
+ return "\n".join(lines)
403
+
404
+ def _keyboard_test(self, name: str, default_props: str) -> str:
405
+ lines = []
406
+ lines.append(' it("handles keyboard interaction", () => {')
407
+ props_str = (" " + default_props) if default_props else ""
408
+ lines.append(f" render(<{name}{props_str} />);")
409
+ lines.append(
410
+ f' const el = screen.getByTestId("{name.lower()}");'
411
+ )
412
+ lines.append(" el.focus();")
413
+ lines.append(
414
+ ' fireEvent.keyDown(el, { key: "Enter", code: "Enter" });'
415
+ )
416
+ lines.append(" expect(el).toBeDefined();")
417
+ lines.append(" });")
418
+ lines.append("")
419
+ return "\n".join(lines)
420
+
421
+ def _snapshot_test(self, name: str, default_props: str) -> str:
422
+ props_str = (" " + default_props) if default_props else ""
423
+ lines = []
424
+ lines.append(' it("matches snapshot", () => {')
425
+ lines.append(
426
+ f" const {{ container }} = render(<{name}{props_str} />);"
427
+ )
428
+ lines.append(" expect(container.firstChild).toMatchSnapshot();")
429
+ lines.append(" });")
430
+ return "\n".join(lines)
431
+
432
+ # ----- String helpers ---------------------------------------------
433
+
434
+ @staticmethod
435
+ def _safe_name(name: str) -> str:
436
+ """Produce a valid JS identifier from ``name``.
437
+
438
+ Keeps PascalCase if already valid; otherwise strips non-word
439
+ characters.
440
+ """
441
+ cleaned = re.sub(r"\W+", "", name or "")
442
+ if not cleaned:
443
+ return "Component"
444
+ if cleaned[0].isdigit():
445
+ cleaned = "C" + cleaned
446
+ return cleaned
447
+
448
+ @staticmethod
449
+ def _kebab_case(name: str) -> str:
450
+ step = re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
451
+ step = re.sub(r"[_\s]+", "-", step)
452
+ step = re.sub(r"-{2,}", "-", step)
453
+ return step.strip("-")
@@ -0,0 +1,83 @@
1
+ # Magic Modules Design Tokens
2
+
3
+ Design tokens are the named constants that describe Loki Mode's visual
4
+ language: colors, spacing steps, typography, border radii, shadows, and
5
+ motion curves. Generated components read these tokens so every new screen
6
+ stays consistent with the rest of the dashboard and web app.
7
+
8
+ ## Files
9
+
10
+ - `defaults.json` -- baseline tokens shipped with Magic Modules. Reflects the
11
+ live Loki Mode dashboard palette (primary `#553DE9`, success `#1FC5A8`,
12
+ etc.) and the Inter / JetBrains Mono type pairing.
13
+ - `.loki/magic/tokens.json` (per project) -- optional override file. Merged
14
+ on top of the defaults at load time, so you only have to list the values
15
+ you want to change.
16
+
17
+ ## Override defaults
18
+
19
+ Create `.loki/magic/tokens.json` at your project root:
20
+
21
+ ```json
22
+ {
23
+ "colors": {
24
+ "primary": "#1E88E5",
25
+ "success": "#2E7D32"
26
+ },
27
+ "radii": {
28
+ "md": "10px"
29
+ }
30
+ }
31
+ ```
32
+
33
+ The loader deep-merges this file onto `defaults.json`, so any key you omit
34
+ falls back to the shipped value.
35
+
36
+ ## Extract from an existing codebase
37
+
38
+ Run the extractor to learn tokens from what's already in the repo (CSS
39
+ custom properties, Tailwind spacing utilities, hex literals, font-family
40
+ declarations, box-shadow values, border-radius declarations):
41
+
42
+ ```bash
43
+ loki magic tokens extract # dry run, prints observed tokens
44
+ loki magic tokens extract --save # writes to .loki/magic/tokens.json
45
+ ```
46
+
47
+ Programmatic equivalent:
48
+
49
+ ```python
50
+ from magic.core.design_tokens import DesignTokens
51
+
52
+ tokens = DesignTokens(project_dir=".")
53
+ observed = tokens.extract_from_codebase(save=True)
54
+ ```
55
+
56
+ The extractor scans (relative to the project root):
57
+
58
+ - `web-app/src/index.css` and any other `web-app/src/**/*.css`
59
+ - `dashboard-ui/**/*.css` and `dashboard-ui/loki-unified-styles.js`
60
+ - `dashboard/static/**/*.css`
61
+ - `.tsx` and `.jsx` files under `web-app/src/` and `dashboard-ui/` for
62
+ Tailwind spacing classes and inline hex colors
63
+
64
+ ## How generated components use tokens
65
+
66
+ The generator calls `DesignTokens.to_prompt_context()` and injects the
67
+ resulting block into the component-generation prompt so the model always
68
+ knows the approved palette, spacing scale, and type stack. Example output:
69
+
70
+ ```
71
+ DESIGN TOKENS:
72
+ Colors: primary=#553DE9, success=#1FC5A8, danger=#C45B5B, ...
73
+ Spacing: xs=4px, sm=8px, md=12px, lg=16px, xl=24px
74
+ Typography: Inter (body), JetBrains Mono (code)
75
+ Radii: sm=4px, md=6px, lg=8px, xl=12px, full=9999px
76
+ ```
77
+
78
+ Additional renderers for post-processing generated code:
79
+
80
+ - `to_tailwind_config()` -- returns a Tailwind `theme.extend` dict you can
81
+ drop into a component-local `tailwind.config.js`.
82
+ - `to_css_variables()` -- returns a `:root { --color-primary: ... }` CSS
83
+ block for plain-CSS components.
@@ -0,0 +1,59 @@
1
+ {
2
+ "colors": {
3
+ "primary": "#553DE9",
4
+ "primary-hover": "#4832c7",
5
+ "primary-foreground": "#FFFFFF",
6
+ "success": "#1FC5A8",
7
+ "warning": "#D4A03C",
8
+ "danger": "#C45B5B",
9
+ "info": "#2F71E3",
10
+ "background": "#FFFEFB",
11
+ "card": "#FFFFFF",
12
+ "border": "#ECEAE3",
13
+ "muted": "#939084",
14
+ "ink": "#36342E",
15
+ "dark-background": "#0F0F11",
16
+ "dark-card": "#1A1A1E",
17
+ "dark-border": "#2A2A30",
18
+ "dark-ink": "#E8E6E3"
19
+ },
20
+ "spacing": {
21
+ "xs": "4px",
22
+ "sm": "8px",
23
+ "md": "12px",
24
+ "lg": "16px",
25
+ "xl": "24px",
26
+ "2xl": "32px",
27
+ "3xl": "48px"
28
+ },
29
+ "typography": {
30
+ "font-sans": "Inter, -apple-system, sans-serif",
31
+ "font-mono": "JetBrains Mono, monospace",
32
+ "font-serif": "DM Serif Display, serif",
33
+ "size-xs": "11px",
34
+ "size-sm": "13px",
35
+ "size-base": "14px",
36
+ "size-lg": "16px",
37
+ "size-xl": "20px",
38
+ "size-h1": "28px"
39
+ },
40
+ "radii": {
41
+ "sm": "4px",
42
+ "md": "6px",
43
+ "lg": "8px",
44
+ "xl": "12px",
45
+ "full": "9999px"
46
+ },
47
+ "shadows": {
48
+ "sm": "0 1px 2px rgba(0,0,0,0.05)",
49
+ "md": "0 4px 6px -1px rgba(0,0,0,0.1)",
50
+ "lg": "0 8px 25px rgba(0,0,0,0.1)",
51
+ "focus-ring": "0 0 0 3px rgba(85,61,233,0.15)"
52
+ },
53
+ "motion": {
54
+ "duration-fast": "100ms",
55
+ "duration-normal": "200ms",
56
+ "duration-slow": "300ms",
57
+ "ease-default": "cubic-bezier(0.4, 0, 0.2, 1)"
58
+ }
59
+ }
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.79.0'
60
+ __version__ = '6.80.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.79.0",
3
+ "version": "6.80.0",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
@@ -75,6 +75,7 @@
75
75
  "events/",
76
76
  "memory/",
77
77
  "learning/",
78
+ "magic/",
78
79
  "templates/",
79
80
  "dashboard/*.py",
80
81
  "dashboard/static/",