@oratis/lisa 0.2.0 → 0.3.1

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.
Files changed (89) hide show
  1. package/README.md +102 -9
  2. package/README.zh-CN.md +101 -9
  3. package/completions/_lisa +1 -0
  4. package/completions/lisa.bash +1 -1
  5. package/completions/lisa.fish +1 -0
  6. package/dist/agent.d.ts.map +1 -1
  7. package/dist/agent.js +12 -0
  8. package/dist/agent.js.map +1 -1
  9. package/dist/channels/imessage.d.ts +16 -0
  10. package/dist/channels/imessage.d.ts.map +1 -1
  11. package/dist/channels/imessage.js +29 -8
  12. package/dist/channels/imessage.js.map +1 -1
  13. package/dist/cli.js +23 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/env.js +6 -1
  16. package/dist/env.js.map +1 -1
  17. package/dist/heartbeat/config.d.ts +10 -0
  18. package/dist/heartbeat/config.d.ts.map +1 -1
  19. package/dist/heartbeat/config.js +15 -3
  20. package/dist/heartbeat/config.js.map +1 -1
  21. package/dist/heartbeat/install.d.ts.map +1 -1
  22. package/dist/heartbeat/install.js +80 -15
  23. package/dist/heartbeat/install.js.map +1 -1
  24. package/dist/heartbeat/runner.d.ts.map +1 -1
  25. package/dist/heartbeat/runner.js +30 -0
  26. package/dist/heartbeat/runner.js.map +1 -1
  27. package/dist/integrations/claude-code/parser.d.ts +56 -0
  28. package/dist/integrations/claude-code/parser.d.ts.map +1 -0
  29. package/dist/integrations/claude-code/parser.js +224 -0
  30. package/dist/integrations/claude-code/parser.js.map +1 -0
  31. package/dist/integrations/claude-code/watcher.d.ts +127 -0
  32. package/dist/integrations/claude-code/watcher.d.ts.map +1 -0
  33. package/dist/integrations/claude-code/watcher.js +361 -0
  34. package/dist/integrations/claude-code/watcher.js.map +1 -0
  35. package/dist/llm.d.ts +0 -6
  36. package/dist/llm.d.ts.map +1 -1
  37. package/dist/llm.js +0 -10
  38. package/dist/llm.js.map +1 -1
  39. package/dist/memory/vector.d.ts +5 -1
  40. package/dist/memory/vector.d.ts.map +1 -1
  41. package/dist/memory/vector.js +58 -1
  42. package/dist/memory/vector.js.map +1 -1
  43. package/dist/mood-bus.d.ts +11 -2
  44. package/dist/mood-bus.d.ts.map +1 -1
  45. package/dist/mood-bus.js +15 -2
  46. package/dist/mood-bus.js.map +1 -1
  47. package/dist/sessions/store.d.ts +1 -5
  48. package/dist/sessions/store.d.ts.map +1 -1
  49. package/dist/sessions/store.js +0 -11
  50. package/dist/sessions/store.js.map +1 -1
  51. package/dist/soul/lock.d.ts +38 -0
  52. package/dist/soul/lock.d.ts.map +1 -0
  53. package/dist/soul/lock.js +103 -0
  54. package/dist/soul/lock.js.map +1 -0
  55. package/dist/soul/paths.d.ts.map +1 -1
  56. package/dist/soul/paths.js +12 -6
  57. package/dist/soul/paths.js.map +1 -1
  58. package/dist/soul/slug.d.ts +40 -0
  59. package/dist/soul/slug.d.ts.map +1 -0
  60. package/dist/soul/slug.js +78 -0
  61. package/dist/soul/slug.js.map +1 -0
  62. package/dist/soul/store.d.ts +12 -1
  63. package/dist/soul/store.d.ts.map +1 -1
  64. package/dist/soul/store.js +40 -23
  65. package/dist/soul/store.js.map +1 -1
  66. package/dist/soul/tools.d.ts.map +1 -1
  67. package/dist/soul/tools.js +10 -3
  68. package/dist/soul/tools.js.map +1 -1
  69. package/dist/tools/task.d.ts +0 -1
  70. package/dist/tools/task.d.ts.map +1 -1
  71. package/dist/tools/task.js +0 -3
  72. package/dist/tools/task.js.map +1 -1
  73. package/dist/tools/web_fetch.d.ts +10 -0
  74. package/dist/tools/web_fetch.d.ts.map +1 -1
  75. package/dist/tools/web_fetch.js +48 -16
  76. package/dist/tools/web_fetch.js.map +1 -1
  77. package/dist/web/assets/lisa-mascot.png +0 -0
  78. package/dist/web/island.d.ts +12 -0
  79. package/dist/web/island.d.ts.map +1 -0
  80. package/dist/web/island.js +948 -0
  81. package/dist/web/island.js.map +1 -0
  82. package/dist/web/lisa-html.d.ts +29 -0
  83. package/dist/web/lisa-html.d.ts.map +1 -0
  84. package/dist/web/lisa-html.js +2095 -0
  85. package/dist/web/lisa-html.js.map +1 -0
  86. package/dist/web/server.d.ts.map +1 -1
  87. package/dist/web/server.js +86 -1601
  88. package/dist/web/server.js.map +1 -1
  89. package/package.json +13 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"island.js","sourceRoot":"","sources":["../../src/web/island.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,CAAC,MAAM,WAAW,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAw6B1B,CAAC"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Lisa.app chat HTML — the main /web shell.
3
+ *
4
+ * Extracted out of server.ts so the visual layer (CSS + body markup +
5
+ * client JS) can evolve as one piece without bloating the server module.
6
+ *
7
+ * Design language follows reference/mockups/lisa-app-redesign.html:
8
+ * - Glass-morphism dark UI with cyan (#6ad4ff) brand accent
9
+ * - 280px sidebar (identity card · currently wanting · Claude monitor ·
10
+ * last reflection · SOUL/SKILLS/MEMORY/TOOLS row · session badge)
11
+ * - Right pane: chat log + composer
12
+ *
13
+ * IDs preserved from the previous pixel-art shell so the existing
14
+ * client-side JS (history loading, mood updates, modal panels, birth
15
+ * ritual, cfg gate, attachments, send + SSE streaming) keeps working
16
+ * unchanged:
17
+ * log, input, form, sendBtn, sessionId, fileInput, attachPreview,
18
+ * mascot, mascotTag, modalBg, modalTitle, modalBody, modalClose,
19
+ * cfgOverlay, cfgForm, cfgAnthropic, cfgOpenai, cfgSave, cfgError,
20
+ * birthOverlay, birthSteps, birthFinal, birthEnter, birthError,
21
+ * attachBtn
22
+ *
23
+ * New IDs for the sidebar live blocks (wired in the trailing
24
+ * "sidebar live wiring" script section):
25
+ * identitySub, sbDesire, sbClaudeCard, sbClaudeCount, sbClaudeRows,
26
+ * sbReflection, sbReflectionBody, sbSessionBadge
27
+ */
28
+ export declare const MAIN_HTML = "<!doctype html>\n<html lang=\"en\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>LISA</title>\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<meta name=\"theme-color\" content=\"#07091a\">\n<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">\n<meta name=\"apple-mobile-web-app-title\" content=\"LISA\">\n<link rel=\"apple-touch-icon\" href=\"/assets/lisa-mascot.png\">\n<link rel=\"icon\" type=\"image/png\" href=\"/assets/lisa-mascot.png\">\n<style>\n :root {\n color-scheme: dark;\n\n /* New design tokens (mockup) */\n --accent: #6ad4ff;\n --accent-soft: rgba(106, 212, 255, 0.13);\n --accent-glow: rgba(106, 212, 255, 0.27);\n --warm: #ffd066;\n --dream: #b487ff;\n --claude: #ff8c42;\n --err-color: #ff5577;\n\n --bg-deep: #07091a;\n --bg-1: #0b1024;\n --bg-2: #11163a;\n --bg-3: #1a1f4a;\n --bg-card: rgba(20, 26, 64, 0.65);\n --bg-card-strong: rgba(20, 26, 64, 0.88);\n --border-new: rgba(255, 255, 255, 0.07);\n --border-strong: rgba(255, 255, 255, 0.14);\n\n --fg: #e8eaff;\n --fg-2: #aeb5d3;\n --fg-3: #6c7398;\n --fg-faint: #444a6e;\n\n /* Legacy tokens \u2014 kept so the unchanged modal / cfg / birth\n overlay styles below still resolve. The new shell + chat use\n the modern tokens above. */\n --bg: #0a0d2b;\n --panel: #1a1f4d;\n --panel-light: #2a3270;\n --border: #6a7ad9;\n --border-light: #a4b2ff;\n --text: #e7ecff;\n --text-dim: #8090c0;\n --you: #6cf6e1;\n --lisa: #ffd167;\n --tool: #ff7eb6;\n --error: #ff5577;\n }\n * { box-sizing: border-box; }\n html, body {\n height: 100%;\n margin: 0;\n background: #000;\n font-family: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Inter\", system-ui, sans-serif;\n -webkit-font-smoothing: antialiased;\n color: var(--fg);\n overflow: hidden;\n }\n\n /* \u2500\u2500 App shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .frame {\n height: 100vh;\n width: 100vw;\n display: grid;\n grid-template-columns: 280px 1fr;\n grid-template-rows: 36px 1fr;\n grid-template-areas:\n \"titlebar titlebar\"\n \"sidebar main\";\n background:\n radial-gradient(ellipse at 30% 20%, #1a1238 0%, transparent 50%),\n radial-gradient(ellipse at 80% 70%, #0a1f3a 0%, transparent 60%),\n linear-gradient(180deg, var(--bg-1) 0%, var(--bg-deep) 100%);\n overflow: hidden;\n }\n\n /* Title bar \u2014 visually shows \"Lisa \u00B7 session-id\". The actual drag\n behavior is handled Swift-side by a transparent NSView overlay\n (DragHandleView) placed on top of the WKWebView for the same 36pt\n strip \u2014 WebKit ignores the CSS -webkit-app-region: drag property,\n so the cosmetic HTML and the functional drag region are two\n separate things.\n Padding-left reserves the ~78pt that the macOS traffic-light\n buttons occupy at top-left. */\n .titlebar {\n grid-area: titlebar;\n background: rgba(7, 9, 26, 0.55);\n backdrop-filter: blur(20px);\n -webkit-backdrop-filter: blur(20px);\n border-bottom: 1px solid var(--border-new);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0 14px 0 78px;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n color: var(--fg-2);\n /* nothing here should ever capture pointer events \u2014 the NSView\n overlay above handles dragging, and there are no interactive\n elements in the HTML titlebar. */\n user-select: none;\n pointer-events: none;\n }\n .titlebar .session-tag {\n color: var(--fg-3);\n font-weight: 400;\n margin-left: 6px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11px;\n letter-spacing: 0;\n }\n\n /* \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .sidebar {\n grid-area: sidebar;\n background: rgba(7, 9, 26, 0.4);\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n border-right: 1px solid var(--border-new);\n overflow-y: auto;\n padding: 20px 16px 16px;\n display: flex;\n flex-direction: column;\n gap: 18px;\n }\n\n /* Identity card */\n .identity {\n display: grid;\n grid-template-columns: 56px 1fr;\n gap: 12px;\n align-items: center;\n padding: 12px;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n }\n .identity .avatar-wrap {\n width: 56px;\n height: 56px;\n border-radius: 50%;\n position: relative;\n border: 1px solid var(--border-strong);\n box-shadow: 0 0 0 3px var(--accent-soft);\n background: #15192a;\n }\n .identity .avatar-wrap img {\n width: 100%;\n height: 100%;\n border-radius: 50%;\n object-fit: cover;\n object-position: 50% 22%;\n image-rendering: pixelated;\n display: block;\n transition: opacity 250ms ease;\n user-select: none;\n -webkit-user-drag: none;\n }\n .identity .avatar-wrap img.fading { opacity: 0; }\n .identity .avatar-wrap::after {\n content: \"\";\n position: absolute;\n right: -2px;\n bottom: -2px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: #4ade80;\n border: 2px solid var(--bg-1);\n }\n .identity h1 {\n margin: 0 0 2px;\n font-size: 15px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: var(--fg);\n }\n .identity .sub {\n margin: 0;\n font-size: 11px;\n color: var(--fg-3);\n }\n .identity .mood {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n margin-top: 4px;\n font-size: 10.5px;\n color: var(--accent);\n text-transform: uppercase;\n letter-spacing: 0.08em;\n font-weight: 600;\n }\n .identity .mood::before {\n content: \"\";\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--accent);\n box-shadow: 0 0 6px var(--accent-glow);\n }\n\n /* Sidebar plain text section (\"currently wanting\") */\n .sb-section { display: flex; flex-direction: column; gap: 6px; }\n .sb-section h2 {\n margin: 0 0 2px;\n font-size: 10.5px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n padding-left: 4px;\n }\n .sb-section .body-text {\n margin: 0;\n font-size: 12px;\n line-height: 1.55;\n color: var(--fg-2);\n padding: 0 4px;\n }\n\n /* Live mini-cards (Claude monitor / last reflection) */\n .card {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 12px;\n padding: 10px 12px;\n font-size: 12px;\n color: var(--fg-2);\n line-height: 1.5;\n }\n .card.tint-claude {\n border-color: rgba(255, 140, 66, 0.20);\n background: linear-gradient(180deg, rgba(255, 140, 66, 0.06), rgba(255, 140, 66, 0.02));\n }\n .card.tint-idle {\n border-color: rgba(255, 208, 102, 0.22);\n background: linear-gradient(180deg, rgba(255, 208, 102, 0.07), rgba(255, 208, 102, 0.02));\n }\n .card .h {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 4px;\n }\n .card .h .left {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 10.5px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--claude);\n }\n .card.tint-idle .h .left { color: var(--warm); }\n .card .h .count {\n background: rgba(255, 140, 66, 0.16);\n color: var(--claude);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 7px;\n border-radius: 8px;\n }\n .card.tint-idle .h .count {\n background: rgba(255, 208, 102, 0.16);\n color: var(--warm);\n }\n .session-row {\n display: grid;\n grid-template-columns: 7px 1fr auto;\n align-items: center;\n gap: 7px;\n padding: 5px 0;\n font-size: 11.5px;\n border-top: 1px dashed rgba(255, 140, 66, 0.10);\n cursor: default;\n }\n .session-row:first-of-type { border-top: 0; }\n .session-row .pip {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--fg-faint);\n }\n .session-row .pip.working { background: var(--claude); animation: pulse 1.8s ease-in-out infinite; }\n .session-row .pip.waiting { background: var(--claude); }\n .session-row .pip.error { background: var(--err-color); }\n .session-row .name {\n color: var(--fg);\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n .session-row .when {\n color: var(--fg-3);\n font-variant-numeric: tabular-nums;\n font-size: 10.5px;\n }\n .session-empty {\n color: var(--fg-faint);\n font-size: 11.5px;\n font-style: italic;\n padding: 4px 0 2px;\n }\n @keyframes pulse {\n 0%, 100% { opacity: 0.35; }\n 50% { opacity: 1; }\n }\n\n /* Compact SOUL / SKILLS / MEMORY / TOOLS row */\n .badges {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 6px;\n }\n .badge {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n color: var(--fg-2);\n font-family: inherit;\n font-size: 10.5px;\n font-weight: 600;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 7px 8px;\n border-radius: 8px;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n transition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n }\n .badge:hover {\n background: var(--bg-card-strong);\n color: var(--fg);\n border-color: var(--border-strong);\n }\n .badge img {\n width: 14px;\n height: 14px;\n image-rendering: pixelated;\n opacity: 0.8;\n }\n\n /* Sidebar footer \u2014 session id + sessions count */\n .sb-footer {\n margin-top: auto;\n padding-top: 12px;\n border-top: 1px solid var(--border-new);\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--fg-3);\n font-size: 11px;\n }\n .sb-footer .session-id {\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 10px;\n color: var(--fg-3);\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n flex: 1;\n min-width: 0;\n }\n .sb-footer .badge-count {\n background: var(--bg-3);\n color: var(--fg-2);\n border-radius: 6px;\n padding: 1px 6px;\n font-size: 10px;\n font-variant-numeric: tabular-nums;\n flex-shrink: 0;\n }\n\n /* \u2500\u2500 Main pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .main {\n grid-area: main;\n display: grid;\n grid-template-rows: 1fr auto auto;\n overflow: hidden;\n background:\n radial-gradient(ellipse at 50% -30%, rgba(106, 212, 255, 0.06) 0%, transparent 60%),\n linear-gradient(180deg, transparent, rgba(106, 212, 255, 0.015));\n }\n\n #log {\n overflow-y: auto;\n padding: 22px 28px 24px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n scroll-behavior: smooth;\n }\n\n /* Chat author label (.role .you/.lisa) */\n .role {\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n padding: 6px 2px 0;\n margin-top: 6px;\n }\n .role.you { color: var(--accent); text-align: right; }\n .role.lisa { color: var(--warm); }\n\n /* Message bubble */\n .msg {\n display: block;\n max-width: 88%;\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 14px;\n padding: 9px 13px;\n font-size: 13px;\n line-height: 1.55;\n color: var(--fg);\n white-space: pre-wrap;\n word-wrap: break-word;\n }\n /* \"you\" bubbles sit on the right with cyan tint */\n .role.you + .msg {\n margin-left: auto;\n background: linear-gradient(160deg, rgba(106, 212, 255, 0.12), rgba(106, 212, 255, 0.06));\n border-color: rgba(106, 212, 255, 0.25);\n }\n /* attachment label sits compactly under the user bubble */\n .msg.attach-label {\n margin-top: 2px;\n background: transparent;\n border: 0;\n padding: 0 2px;\n font-size: 10.5px;\n color: var(--fg-3);\n max-width: 88%;\n margin-left: auto;\n }\n\n /* Thinking indicator */\n .thinking {\n font-size: 12px;\n color: var(--fg-3);\n font-style: italic;\n padding: 4px 2px;\n letter-spacing: 0.03em;\n }\n\n /* Tool call card */\n .tool-block {\n background: var(--bg-card);\n border: 1px solid var(--border-new);\n border-radius: 10px;\n padding: 8px 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11px;\n color: var(--fg-2);\n margin: 4px 0;\n max-width: 92%;\n }\n .tool-block.tool-error {\n border-color: rgba(255, 85, 119, 0.30);\n background: rgba(255, 85, 119, 0.05);\n }\n .tool-head {\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--accent);\n font-weight: 600;\n }\n .tool-block.tool-error .tool-head { color: var(--err-color); }\n .tool-icon { font-size: 12px; }\n .tool-spinner { color: var(--fg-3); font-weight: 400; margin-left: auto; font-size: 12px; }\n .tool-input {\n color: var(--fg-3);\n margin-top: 4px;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .tool-result {\n color: var(--fg-2);\n margin-top: 4px;\n padding-top: 4px;\n border-top: 1px dashed var(--border-new);\n white-space: pre-wrap;\n max-height: 160px;\n overflow-y: auto;\n }\n\n .err {\n color: var(--err-color);\n font-size: 12px;\n padding: 6px 2px;\n }\n\n /* Idle pulse + while-you-were-away banner injected into #log */\n .idle-pulse {\n color: var(--dream);\n font-size: 12px;\n font-style: italic;\n padding: 4px 2px;\n letter-spacing: 0.03em;\n }\n .idle-block {\n background: linear-gradient(180deg, rgba(180, 135, 255, 0.10), rgba(180, 135, 255, 0.02));\n border: 1px solid rgba(180, 135, 255, 0.30);\n border-radius: 14px;\n padding: 12px 14px;\n margin: 6px 0;\n font-size: 12.5px;\n line-height: 1.6;\n color: var(--fg);\n }\n .idle-head {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.10em;\n color: var(--dream);\n font-weight: 700;\n margin-bottom: 6px;\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .idle-time {\n margin-left: auto;\n font-weight: 400;\n letter-spacing: 0.05em;\n color: var(--fg-3);\n font-size: 10px;\n }\n\n /* Composer */\n #attachPreview {\n padding: 4px 22px 0;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n min-height: 0;\n }\n #attachPreview:empty { padding: 0; }\n .attach-chip {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n color: var(--fg-2);\n font-size: 11px;\n padding: 3px 8px;\n border-radius: 8px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n .attach-rm {\n background: transparent;\n border: 0;\n color: var(--fg-3);\n cursor: pointer;\n font-size: 13px;\n padding: 0;\n line-height: 1;\n }\n .attach-rm:hover { color: var(--err-color); }\n\n #form {\n padding: 14px 20px 18px;\n background: rgba(7, 9, 26, 0.6);\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n border-top: 1px solid var(--border-new);\n display: grid;\n grid-template-columns: 36px 1fr 96px;\n gap: 10px;\n align-items: end;\n }\n\n #attachBtn {\n align-self: stretch;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n border: 0;\n color: var(--fg-3);\n font-size: 17px;\n cursor: pointer;\n border-radius: 10px;\n transition: background 120ms ease, color 120ms ease;\n min-height: 44px;\n }\n #attachBtn:hover { background: var(--bg-card); color: var(--fg); }\n /* Off-screen the file input instead of display:none. WKWebView's\n implicit <label>\u2192<input type=file> click forward doesn't fire on a\n fully display:none input \u2014 the OS file picker silently no-ops.\n Off-screening keeps the input \"live\" while invisible. */\n #fileInput {\n position: absolute;\n width: 1px;\n height: 1px;\n opacity: 0;\n pointer-events: none;\n overflow: hidden;\n }\n\n #input {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 14px;\n color: var(--fg);\n font-family: inherit;\n font-size: 13.5px;\n line-height: 1.45;\n padding: 11px 13px;\n resize: none;\n outline: none;\n width: 100%;\n min-height: 44px;\n max-height: 200px;\n transition: border-color 120ms ease, box-shadow 120ms ease;\n }\n #input:focus {\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n #input::placeholder { color: var(--fg-3); }\n\n #sendBtn {\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n color: #0a1024;\n border: 0;\n border-radius: 14px;\n height: 100%;\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 700;\n letter-spacing: 0.06em;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 10px 14px;\n transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);\n }\n #sendBtn:hover:not(:disabled) {\n transform: translateY(-1px);\n box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);\n }\n #sendBtn:active:not(:disabled) { transform: translateY(0); }\n #sendBtn:disabled { opacity: 0.5; cursor: wait; }\n /* Hide the legacy pixel send icon; the text label is enough in the new theme. */\n #sendBtn img { display: none; }\n\n /* \u2500\u2500 Mobile / narrow \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n @media (max-width: 720px) {\n body {\n padding-top: env(safe-area-inset-top);\n padding-bottom: env(safe-area-inset-bottom);\n }\n .frame {\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr auto;\n }\n .sidebar {\n max-height: 38vh;\n padding: 14px 14px 12px;\n gap: 14px;\n }\n #form {\n grid-template-columns: 36px 1fr 84px;\n padding: 10px 14px 14px;\n }\n #input { font-size: 16px; /* prevents iOS Safari auto-zoom */ }\n }\n\n /* ===================================================================\n Modal panel (skills / memory / tools / soul) \u2014 unchanged from the\n legacy pixel-art shell. Uses the legacy CSS vars declared above.\n =================================================================== */\n .modal-bg {\n position: fixed; inset: 0;\n background: rgba(0, 0, 0, 0.7);\n display: none;\n align-items: center; justify-content: center;\n z-index: 100;\n }\n .modal-bg.open { display: flex; }\n .modal {\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 16px;\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n max-width: 720px;\n width: 90vw;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n }\n .modal-head {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border-new);\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n .modal-title {\n font-size: 13px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--accent);\n }\n .modal-close {\n background: transparent;\n border: 1px solid var(--border-new);\n color: var(--fg-2);\n font-family: inherit;\n font-size: 11px;\n letter-spacing: 0.06em;\n padding: 5px 10px;\n border-radius: 8px;\n cursor: pointer;\n }\n .modal-close:hover { color: var(--err-color); border-color: rgba(255, 85, 119, 0.40); }\n .modal-body {\n padding: 16px 18px;\n overflow-y: auto;\n font-size: 12.5px;\n line-height: 1.55;\n color: var(--fg-2);\n }\n .modal-body h3 {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-transform: uppercase;\n color: var(--accent);\n margin: 14px 0 6px 0;\n border-bottom: 1px dashed var(--border-new);\n padding-bottom: 5px;\n }\n .modal-body h3:first-child { margin-top: 0; }\n .modal-body .item {\n padding: 7px 0;\n border-bottom: 1px dotted var(--border-new);\n }\n .modal-body .item:last-child { border: none; }\n .modal-body .name {\n color: var(--warm);\n font-weight: 600;\n font-size: 12px;\n }\n .modal-body .desc { color: var(--fg-3); margin-top: 2px; }\n .modal-body pre {\n background: rgba(0, 0, 0, 0.30);\n padding: 10px 12px;\n border-left: 2px solid var(--accent);\n border-radius: 4px;\n white-space: pre-wrap;\n margin: 6px 0;\n color: var(--fg);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 11.5px;\n line-height: 1.5;\n }\n .modal-body .empty { color: var(--fg-faint); font-style: italic; }\n\n /* ===================================================================\n Birth ritual overlay \u2014 full-screen, one-time. Uses legacy palette\n intentionally (it's a separate ceremonial moment).\n =================================================================== */\n .birth-overlay {\n position: fixed;\n inset: 0;\n background:\n radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);\n z-index: 9999;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 32px;\n }\n .birth-overlay.open { display: flex; }\n .birth-content {\n position: relative;\n z-index: 2;\n width: min(800px, 95vw);\n max-height: 90vh;\n overflow-y: auto;\n }\n .birth-stars {\n text-align: center;\n color: var(--accent);\n font-size: 14px;\n letter-spacing: 8px;\n text-shadow: 0 0 8px var(--accent-glow);\n animation: starBlink 1.5s steps(3) infinite;\n }\n @keyframes starBlink {\n 0%, 30%, 100% { opacity: 1; }\n 50% { opacity: 0.4; }\n }\n .birth-title {\n text-align: center;\n color: var(--warm);\n font-size: 22px;\n font-weight: 700;\n letter-spacing: 6px;\n margin: 24px 0 32px;\n text-shadow: 0 0 12px rgba(255, 208, 102, 0.45);\n }\n .birth-step {\n margin: 14px 0;\n padding: 10px 16px;\n border-left: 3px solid var(--border-new);\n background: rgba(20, 26, 64, 0.55);\n border-radius: 0 8px 8px 0;\n opacity: 0;\n transform: translateY(8px);\n transition: opacity 0.4s ease-out, transform 0.4s ease-out;\n }\n .birth-step.shown { opacity: 1; transform: translateY(0); }\n .birth-step.active {\n border-left-color: var(--warm);\n background: rgba(255, 208, 102, 0.08);\n }\n .birth-step.done { border-left-color: var(--accent); }\n .birth-step .step-name {\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.10em;\n color: var(--fg-3);\n text-transform: uppercase;\n margin-bottom: 4px;\n }\n .birth-step.active .step-name { color: var(--warm); }\n .birth-step.done .step-name { color: var(--accent); }\n .birth-step .step-detail {\n color: var(--fg);\n font-size: 13px;\n line-height: 1.55;\n word-break: break-word;\n }\n .birth-step .step-cursor {\n display: inline-block;\n width: 6px;\n background: var(--warm);\n height: 0.9em;\n vertical-align: middle;\n animation: blink 0.8s steps(2) infinite;\n }\n @keyframes blink { 50% { opacity: 0; } }\n .birth-final {\n margin-top: 36px;\n text-align: center;\n color: var(--warm);\n font-size: 15px;\n font-weight: 700;\n letter-spacing: 0.10em;\n text-shadow: 0 0 10px rgba(255, 208, 102, 0.4);\n opacity: 0;\n transition: opacity 0.6s ease-in;\n }\n .birth-final.shown { opacity: 1; }\n .birth-enter {\n margin: 24px auto 0;\n display: block;\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n border: 0;\n color: #0a1024;\n font-family: inherit;\n font-weight: 700;\n font-size: 13px;\n padding: 12px 32px;\n border-radius: 12px;\n cursor: pointer;\n letter-spacing: 0.10em;\n opacity: 0;\n transition: opacity 0.6s ease-in, transform 120ms ease;\n box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);\n }\n .birth-enter.shown { opacity: 1; }\n .birth-enter:hover { transform: translateY(-1px); }\n .birth-error {\n color: var(--err-color);\n text-align: center;\n margin-top: 24px;\n font-size: 11.5px;\n letter-spacing: 0.05em;\n }\n\n /* ===================================================================\n API-key config overlay \u2014 same modernized treatment.\n =================================================================== */\n .cfg-overlay {\n position: fixed;\n inset: 0;\n background:\n radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);\n z-index: 9998;\n display: none;\n align-items: center;\n justify-content: center;\n padding: 32px;\n }\n .cfg-overlay.open { display: flex; }\n .cfg-card {\n position: relative;\n z-index: 2;\n width: min(520px, 95vw);\n background: var(--bg-card-strong);\n border: 1px solid var(--border-strong);\n border-radius: 16px;\n backdrop-filter: blur(30px);\n -webkit-backdrop-filter: blur(30px);\n box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);\n padding: 28px 32px;\n }\n .cfg-stars {\n text-align: center;\n color: var(--accent);\n font-size: 12px;\n letter-spacing: 6px;\n text-shadow: 0 0 8px var(--accent-glow);\n animation: starBlink 1.5s steps(3) infinite;\n }\n .cfg-title {\n text-align: center;\n color: var(--warm);\n font-size: 16px;\n font-weight: 700;\n letter-spacing: 6px;\n margin: 16px 0 6px;\n }\n .cfg-sub {\n text-align: center;\n color: var(--fg-3);\n font-size: 12.5px;\n margin-bottom: 22px;\n line-height: 1.5;\n }\n .cfg-sub a { color: var(--accent); text-decoration: underline; }\n .cfg-field { display: block; margin: 14px 0; }\n .cfg-label {\n display: block;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--accent);\n margin-bottom: 6px;\n }\n .cfg-label .opt {\n color: var(--fg-3);\n font-size: 9.5px;\n margin-left: 6px;\n text-transform: none;\n letter-spacing: 0;\n font-weight: 400;\n }\n .cfg-input {\n width: 100%;\n background: var(--bg-card);\n border: 1px solid var(--border-strong);\n border-radius: 10px;\n color: var(--fg);\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 13px;\n padding: 10px 12px;\n }\n .cfg-input:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n .cfg-help {\n color: var(--fg-3);\n font-size: 11px;\n margin-top: 6px;\n }\n .cfg-help code {\n color: var(--fg-2);\n background: rgba(0, 0, 0, 0.3);\n padding: 1px 5px;\n border-radius: 3px;\n font-size: 11px;\n }\n .cfg-actions {\n margin-top: 22px;\n display: flex;\n justify-content: center;\n }\n .cfg-save {\n background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);\n border: 0;\n color: #0a1024;\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 700;\n padding: 11px 28px;\n border-radius: 12px;\n cursor: pointer;\n letter-spacing: 0.10em;\n box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);\n transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;\n }\n .cfg-save:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35); }\n .cfg-save:active { transform: translateY(0); }\n .cfg-save:disabled { opacity: 0.55; cursor: wait; }\n .cfg-error {\n color: var(--err-color);\n font-size: 11px;\n text-align: center;\n margin-top: 14px;\n min-height: 14px;\n }\n</style>\n</head><body>\n\n<div class=\"frame\">\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Title bar (drag zone) \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <div class=\"titlebar\">\n <span>Lisa</span><span class=\"session-tag\" id=\"titlebarSession\"></span>\n </div>\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Sidebar \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <aside class=\"sidebar\">\n\n <!-- Identity card -->\n <div class=\"identity\">\n <div class=\"avatar-wrap\">\n <img id=\"mascot\" src=\"/assets/lisa-mascot.png\" alt=\"Lisa\" draggable=\"false\">\n </div>\n <div>\n <h1>Lisa</h1>\n <p class=\"sub\" id=\"identitySub\">\u2014</p>\n <div class=\"mood\" id=\"mascotTag\">neutral</div>\n </div>\n </div>\n\n <!-- Currently wanting -->\n <div class=\"sb-section\">\n <h2>currently wanting</h2>\n <p class=\"body-text\" id=\"sbDesire\">\u2014</p>\n </div>\n\n <!-- Claude Code monitor -->\n <div class=\"card tint-claude\" id=\"sbClaudeCard\">\n <div class=\"h\">\n <div class=\"left\">claude code</div>\n <div class=\"count\">\u25B6\uFE0E <span id=\"sbClaudeCount\">0</span></div>\n </div>\n <div id=\"sbClaudeRows\">\n <div class=\"session-empty\">(idle)</div>\n </div>\n </div>\n\n <!-- Last reflection (collapsed pointer to the most recent \u2605) -->\n <div class=\"card tint-idle\" id=\"sbReflection\" style=\"display:none;\">\n <div class=\"h\">\n <div class=\"left\">\u2605 last reflection</div>\n </div>\n <p style=\"margin:0; font-size:11.5px; color:var(--fg-2); line-height:1.5;\" id=\"sbReflectionBody\"></p>\n </div>\n\n <!-- SOUL / SKILLS / MEMORY / TOOLS row -->\n <div class=\"badges\">\n <button class=\"badge\" type=\"button\" data-panel=\"soul\"><img src=\"/assets/icon-soul.png\" alt=\"\">SOUL</button>\n <button class=\"badge\" type=\"button\" data-panel=\"skills\"><img src=\"/assets/icon-skill.png\" alt=\"\">SKILLS</button>\n <button class=\"badge\" type=\"button\" data-panel=\"memory\"><img src=\"/assets/icon-memory.png\" alt=\"\">MEMORY</button>\n <button class=\"badge\" type=\"button\" data-panel=\"tools\"><img src=\"/assets/icon-tool.png\" alt=\"\">TOOLS</button>\n </div>\n\n <!-- Footer: current session id -->\n <div class=\"sb-footer\">\n <span class=\"session-id\" id=\"sessionId\">\u2014</span>\n <span class=\"badge-count\" id=\"sbSessionBadge\" title=\"total sessions\">\u00B7</span>\n </div>\n </aside>\n\n <!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Main pane \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n <div class=\"main\">\n\n <!-- Chat log (messages, tool blocks, idle blocks injected here) -->\n <div id=\"log\"></div>\n\n <!-- Attachment chip strip (above composer) -->\n <div id=\"attachPreview\"></div>\n\n <!-- Composer -->\n <form id=\"form\">\n <label id=\"attachBtn\" title=\"Attach file (or paste images directly into the textarea)\">\n <input type=\"file\" id=\"fileInput\" accept=\"image/*,.pdf,.txt,.md,.csv,.json\" multiple>\n \uD83D\uDCCE\n </label>\n <textarea id=\"input\" placeholder=\"Talk to Lisa\u2026 (Enter to send \u00B7 Shift+Enter for newline)\" autofocus></textarea>\n <button type=\"submit\" id=\"sendBtn\">\n <img src=\"/assets/icon-send.png\" alt=\"\">\n SEND \u2192\n </button>\n </form>\n </div>\n</div>\n\n<!-- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 Overlays \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 -->\n\n<div class=\"modal-bg\" id=\"modalBg\">\n <div class=\"modal\">\n <div class=\"modal-head\">\n <div class=\"modal-title\" id=\"modalTitle\">\u2026</div>\n <button class=\"modal-close\" id=\"modalClose\">Close \u00B7 esc</button>\n </div>\n <div class=\"modal-body\" id=\"modalBody\">\u2026</div>\n </div>\n</div>\n\n<!-- API key config overlay (shown if no key configured yet) -->\n<div class=\"cfg-overlay\" id=\"cfgOverlay\">\n <div class=\"cfg-card\">\n <div class=\"cfg-stars\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n <div class=\"cfg-title\">SET \u00B7 API \u00B7 KEY</div>\n <div class=\"cfg-sub\">\n Lisa needs an Anthropic API key to wake up.<br>\n <a href=\"https://console.anthropic.com/\" target=\"_blank\" rel=\"noopener\">Get one at console.anthropic.com</a>\n </div>\n <form id=\"cfgForm\">\n <label class=\"cfg-field\">\n <span class=\"cfg-label\">ANTHROPIC_API_KEY</span>\n <input class=\"cfg-input\" id=\"cfgAnthropic\" type=\"password\" autocomplete=\"off\"\n spellcheck=\"false\" placeholder=\"sk-ant-...\" required>\n </label>\n <label class=\"cfg-field\">\n <span class=\"cfg-label\">OPENAI_API_KEY <span class=\"opt\">(optional \u00B7 for gpt-* models)</span></span>\n <input class=\"cfg-input\" id=\"cfgOpenai\" type=\"password\" autocomplete=\"off\"\n spellcheck=\"false\" placeholder=\"sk-...\">\n </label>\n <div class=\"cfg-help\">\n Saved to <code>~/.lisa/config.env</code> with mode 0600. Stays on this machine.\n </div>\n <div class=\"cfg-actions\">\n <button class=\"cfg-save\" id=\"cfgSave\" type=\"submit\">SAVE &amp; CONTINUE</button>\n </div>\n <div class=\"cfg-error\" id=\"cfgError\"></div>\n </form>\n </div>\n</div>\n\n<!-- Birth ritual full-screen overlay -->\n<div class=\"birth-overlay\" id=\"birthOverlay\">\n <div class=\"birth-content\">\n <div class=\"birth-stars\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n <div class=\"birth-title\">B I R T H \u00B7 R I T U A L</div>\n <div id=\"birthSteps\"></div>\n <div class=\"birth-final\" id=\"birthFinal\"></div>\n <button class=\"birth-enter\" id=\"birthEnter\">ENTER</button>\n <div class=\"birth-error\" id=\"birthError\"></div>\n <div class=\"birth-stars\" style=\"margin-top: 24px;\">\u2726 \u2726 \u2726 \u2726 \u2726</div>\n </div>\n</div>\n\n<script>\nconst log = document.getElementById('log');\nconst input = document.getElementById('input');\nconst form = document.getElementById('form');\nconst sendBtn = document.getElementById('sendBtn');\nconst sessionEl = document.getElementById('sessionId');\nconst fileInput = document.getElementById('fileInput');\nconst attachPreview = document.getElementById('attachPreview');\n\n// \u2500\u2500 Attached files state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet pendingFiles = []; // Array of {name, mediaType, data (base64)}\n\nfunction readFileAsBase64(file) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const dataUrl = reader.result;\n const base64 = dataUrl.split(',')[1];\n resolve(base64);\n };\n reader.onerror = reject;\n reader.readAsDataURL(file);\n });\n}\n\nfunction inferMediaType(file) {\n if (file.type) return file.type;\n const ext = file.name.split('.').pop().toLowerCase();\n const map = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/plain', csv: 'text/csv', json: 'application/json' };\n return map[ext] || 'application/octet-stream';\n}\n\nfunction renderAttachPreview() {\n attachPreview.innerHTML = '';\n pendingFiles.forEach((f, i) => {\n const chip = document.createElement('span');\n chip.className = 'attach-chip';\n chip.textContent = f.name;\n const rm = document.createElement('button');\n rm.type = 'button';\n rm.className = 'attach-rm';\n rm.textContent = '\u00D7';\n rm.onclick = () => { pendingFiles.splice(i, 1); renderAttachPreview(); };\n chip.appendChild(rm);\n attachPreview.appendChild(chip);\n });\n}\n\nfileInput.addEventListener('change', async () => {\n for (const file of fileInput.files) {\n const data = await readFileAsBase64(file);\n pendingFiles.push({ name: file.name, mediaType: inferMediaType(file), data });\n }\n fileInput.value = '';\n renderAttachPreview();\n});\n\n// The natural <label>\u2192<input type=file> click-forwarding is supposed\n// to open the picker without any JS. But in WKWebView under our\n// layout it never reaches runOpenPanel. We add an explicit click\n// listener that forwards the click synchronously (preserving the\n// user-gesture context) and logs to console so we can verify in the\n// inspector if it ever silently no-ops again.\nconst attachBtnEl = document.getElementById('attachBtn');\nif (attachBtnEl) {\n attachBtnEl.addEventListener('click', (ev) => {\n // Don't preventDefault \u2014 that cancels the implicit label-forward\n // and removes the redundant gesture path. Letting both fire is\n // fine because <input type=file>.click() fires the picker only\n // once per user gesture.\n console.log('[attach] click forwarded to fileInput');\n try {\n fileInput.click();\n } catch (err) {\n console.error('[attach] fileInput.click failed:', err);\n }\n });\n}\n\n// Paste-to-attach: when the user has copied an image (screenshot,\n// image from a webpage, etc.) and presses \u2318V, pull the image off the\n// clipboard and add it to pendingFiles \u2014 same path the file picker\n// uses. Plain-text paste falls through to default behavior.\n//\n// We listen at the DOCUMENT level so paste works whether the textarea\n// is focused, the chat log is focused, or focus is on a tool-block.\n// (Some users press \u2318V right after launching the app, before clicking\n// anywhere \u2014 element-scoped handlers miss that.)\n//\n// For text paste we MUST NOT preventDefault \u2014 let the browser route\n// it to the focused element. Only intercept when we detect image\n// items.\nasync function handlePastedClipboard(ev) {\n const cb = ev.clipboardData;\n if (!cb) return;\n\n // Two clipboard surfaces \u2014 items (modern, exposes pasteboard files)\n // and files (older, doesn't include MIME type for some types). We\n // dedupe via the underlying File reference.\n const fileMap = new Map(); // File \u2192 mediaType (string)\n if (cb.items && cb.items.length) {\n for (const item of cb.items) {\n if (item.kind !== 'file') continue;\n if (!item.type || !item.type.startsWith('image/')) continue;\n const f = item.getAsFile();\n if (f) fileMap.set(f, item.type);\n }\n }\n if (cb.files && cb.files.length) {\n for (const f of cb.files) {\n if (f.type && f.type.startsWith('image/')) {\n if (!fileMap.has(f)) fileMap.set(f, f.type || 'image/png');\n }\n }\n }\n\n if (fileMap.size === 0) return; // text paste \u2014 leave default behavior alone\n\n ev.preventDefault();\n // Keep focus on the textarea so the user can keep typing right after.\n try { input.focus(); } catch (_) {}\n\n for (const [file, mediaType] of fileMap) {\n try {\n const data = await readFileAsBase64(file);\n const ext = (mediaType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');\n const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);\n const name = file.name && file.name !== 'image.png'\n ? file.name\n : 'pasted-' + ts + '.' + ext;\n pendingFiles.push({ name, mediaType, data });\n } catch (err) {\n console.warn('[paste] failed to read image:', err);\n }\n }\n renderAttachPreview();\n}\n\ninput.addEventListener('paste', (ev) => { void handlePastedClipboard(ev); });\n// Document-level fallback. If focus is somewhere other than the\n// textarea (e.g. just-launched, no focus yet), the textarea-level\n// paste listener never fires; this one catches it.\ndocument.addEventListener('paste', (ev) => {\n // Avoid double-firing when the textarea ALREADY handled it: if\n // ev.defaultPrevented is set, the textarea listener already\n // consumed the paste and added the file.\n if (ev.defaultPrevented) return;\n void handlePastedClipboard(ev);\n});\n\n// Surface session id from server header on first request. The titlebar\n// shows the same id with a leading \"\u00B7\" separator after the \"Lisa\" label.\nfetch('/session').then(r => r.json()).then(s => {\n sessionEl.textContent = s.id;\n const titlebarSession = document.getElementById('titlebarSession');\n if (titlebarSession) titlebarSession.textContent = '\u00B7 ' + s.id;\n});\n\n// \u2500\u2500 Persistent /events SSE: mood updates + idle messages + Claude\n// activity, lifetime of page.\nfunction connectEvents() {\n const es = new EventSource('/events');\n let idlePulseEl = null;\n es.addEventListener('message', (e) => {\n const ev = JSON.parse(e.data);\n if (ev.type === 'mood') {\n setMood(ev.slug);\n } else if (ev.type === 'idle_start') {\n if (!idlePulseEl) {\n idlePulseEl = document.createElement('div');\n idlePulseEl.className = 'idle-pulse';\n idlePulseEl.textContent = '\u22EF Lisa is thinking on her own time \u22EF';\n log.appendChild(idlePulseEl);\n log.scrollTop = log.scrollHeight;\n }\n } else if (ev.type === 'idle_message') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n const block = document.createElement('div');\n block.className = 'idle-block';\n const head = document.createElement('div');\n head.className = 'idle-head';\n head.textContent = '\u2605 WHILE YOU WERE AWAY';\n const time = document.createElement('span');\n time.className = 'idle-time';\n try { time.textContent = new Date(ev.at).toLocaleTimeString(); } catch {}\n head.appendChild(time);\n block.appendChild(head);\n const bodyEl = document.createElement('div');\n bodyEl.textContent = ev.text;\n block.appendChild(bodyEl);\n log.appendChild(block);\n log.scrollTop = log.scrollHeight;\n // sidebar reflection card mirrors the latest while-you-were-away\n if (typeof updateReflection === 'function') updateReflection(ev.text);\n } else if (ev.type === 'idle_done') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n } else if (ev.type === 'idle_error') {\n if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }\n const e2 = document.createElement('div');\n e2.className = 'err';\n e2.textContent = '[idle error] ' + ev.message;\n log.appendChild(e2);\n } else if (ev.type === 'claude_session_update') {\n // Sidebar Claude monitor card refresh \u2014 defined later in the\n // \"sidebar live wiring\" block.\n if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();\n }\n });\n es.onerror = () => {\n es.close();\n setTimeout(connectEvents, 3000); // reconnect\n };\n}\nconnectEvents();\n\n// \u2500\u2500 API key config gate: show overlay if no key is configured \u2500\u2500\u2500\u2500\u2500\nconst cfgOverlay = document.getElementById('cfgOverlay');\nconst cfgForm = document.getElementById('cfgForm');\nconst cfgAnthropic = document.getElementById('cfgAnthropic');\nconst cfgOpenai = document.getElementById('cfgOpenai');\nconst cfgSaveBtn = document.getElementById('cfgSave');\nconst cfgError = document.getElementById('cfgError');\n\ncfgForm.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n cfgError.textContent = '';\n const anthropic = cfgAnthropic.value.trim();\n const openai = cfgOpenai.value.trim();\n if (!anthropic) {\n cfgError.textContent = 'ANTHROPIC_API_KEY is required.';\n return;\n }\n cfgSaveBtn.disabled = true;\n try {\n const res = await fetch('/api/config/save', {\n method: 'POST',\n headers: {'content-type': 'application/json'},\n body: JSON.stringify({ anthropicKey: anthropic, openaiKey: openai || undefined }),\n });\n if (!res.ok) {\n const txt = await res.text().catch(() => '');\n cfgError.textContent = 'Save failed: HTTP ' + res.status + (txt ? ' \u2014 ' + txt.slice(0, 120) : '');\n cfgSaveBtn.disabled = false;\n return;\n }\n cfgAnthropic.value = '';\n cfgOpenai.value = '';\n cfgOverlay.classList.remove('open');\n maybeBirth();\n } catch (err) {\n cfgError.textContent = 'Save failed: ' + err.message;\n cfgSaveBtn.disabled = false;\n }\n});\n\n// \u2500\u2500 Birth ritual: show overlay if Lisa hasn't been born yet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst birthOverlay = document.getElementById('birthOverlay');\nconst birthStepsEl = document.getElementById('birthSteps');\nconst birthFinalEl = document.getElementById('birthFinal');\nconst birthEnterBtn = document.getElementById('birthEnter');\nconst birthErrorEl = document.getElementById('birthError');\n\nbirthEnterBtn.addEventListener('click', () => {\n birthOverlay.classList.remove('open');\n setTimeout(() => location.reload(), 300);\n});\n\nasync function maybeBirth() {\n const status = await fetch('/api/soul').then(r => r.json());\n if (status.born) return;\n birthOverlay.classList.add('open');\n startBirthStream();\n}\n\nfunction appendBirthStep(step) {\n const prevActive = birthStepsEl.querySelector('.birth-step.active');\n if (prevActive) {\n prevActive.classList.remove('active');\n prevActive.classList.add('done');\n }\n const div = document.createElement('div');\n div.className = 'birth-step active';\n const name = document.createElement('div');\n name.className = 'step-name';\n name.textContent = step;\n div.appendChild(name);\n const detail = document.createElement('div');\n detail.className = 'step-detail';\n const cursor = document.createElement('span');\n cursor.className = 'step-cursor';\n detail.appendChild(cursor);\n div.appendChild(detail);\n birthStepsEl.appendChild(div);\n setTimeout(() => div.classList.add('shown'), 50);\n return detail;\n}\n\nfunction typewriter(el, text, done) {\n const cursor = el.querySelector('.step-cursor');\n if (cursor) cursor.remove();\n let i = 0;\n const speed = Math.max(8, Math.min(28, 600 / text.length));\n function tick() {\n if (i >= text.length) {\n const c = document.createElement('span');\n c.className = 'step-cursor';\n el.appendChild(c);\n done && done();\n return;\n }\n el.appendChild(document.createTextNode(text[i]));\n i++;\n el.parentElement.parentElement.scrollTop = el.parentElement.parentElement.scrollHeight;\n setTimeout(tick, speed);\n }\n tick();\n}\n\nasync function startBirthStream() {\n birthErrorEl.textContent = '';\n let currentDetail = null;\n let queue = [];\n let processing = false;\n\n function processQueue() {\n if (processing) return;\n if (queue.length === 0) return;\n processing = true;\n const ev = queue.shift();\n if (ev.kind === 'step') {\n currentDetail = appendBirthStep(ev.name);\n typewriter(currentDetail, ev.detail || '', () => {\n processing = false;\n processQueue();\n });\n } else if (ev.kind === 'done') {\n const last = birthStepsEl.querySelector('.birth-step.active');\n if (last) { last.classList.remove('active'); last.classList.add('done'); }\n birthFinalEl.textContent = ev.message;\n birthFinalEl.classList.add('shown');\n birthEnterBtn.classList.add('shown');\n processing = false;\n } else if (ev.kind === 'error') {\n birthErrorEl.textContent = ev.message;\n processing = false;\n }\n }\n\n try {\n const res = await fetch('/api/birth', { method: 'POST' });\n if (!res.ok) {\n birthErrorEl.textContent = 'Birth failed: HTTP ' + res.status + '. Check ANTHROPIC_API_KEY.';\n return;\n }\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += decoder.decode(value, { stream: true });\n let idx;\n while ((idx = buf.indexOf('\\n\\n')) >= 0) {\n const event = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const m = event.match(/^data: (.*)$/m);\n if (!m) continue;\n const ev = JSON.parse(m[1]);\n queue.push(ev);\n processQueue();\n }\n }\n } catch (err) {\n birthErrorEl.textContent = 'Birth failed: ' + err.message;\n }\n}\n\nasync function startupGate() {\n let cfg;\n try {\n cfg = await fetch('/api/config/status').then(r => r.json());\n } catch {\n return;\n }\n if (!cfg.configured) {\n cfgOverlay.classList.add('open');\n setTimeout(() => cfgAnthropic.focus(), 50);\n return;\n }\n maybeBirth();\n}\nstartupGate();\n\n// \u2500\u2500 history load & infinite-scroll \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet historyPage = 0;\nlet historyLoading = false;\nlet historyExhausted = false;\n\nfunction textOfMessage(msg) {\n if (typeof msg.content === 'string') return msg.content.trim();\n if (!Array.isArray(msg.content)) return '';\n return msg.content\n .filter(b => b.type === 'text')\n .map(b => b.text)\n .join('')\n .trim();\n}\n\nfunction prependHistoryMessages(messages) {\n const fragment = document.createDocumentFragment();\n for (const msg of messages) {\n const text = textOfMessage(msg);\n if (!text) continue;\n const roleDiv = document.createElement('div');\n roleDiv.className = 'role ' + (msg.role === 'user' ? 'you' : 'lisa');\n roleDiv.textContent = msg.role === 'user' ? 'YOU' : 'LISA';\n const span = document.createElement('span');\n span.className = 'msg';\n span.textContent = text;\n fragment.appendChild(roleDiv);\n fragment.appendChild(span);\n }\n log.insertBefore(fragment, log.firstChild);\n}\n\nasync function loadHistoryPage() {\n if (historyLoading || historyExhausted) return;\n historyLoading = true;\n const prevScrollHeight = log.scrollHeight;\n try {\n const res = await fetch('/api/history?page=' + historyPage);\n const data = await res.json();\n if (data.messages && data.messages.length) {\n prependHistoryMessages(data.messages);\n log.scrollTop = log.scrollHeight - prevScrollHeight;\n historyPage++;\n }\n if (!data.hasMore) {\n historyExhausted = true;\n if (historyPage > 1) {\n const marker = document.createElement('div');\n marker.style.cssText = 'text-align:center;color:var(--fg-3);font-size:11px;padding:8px 0;letter-spacing:0.06em;';\n marker.textContent = '\u2014 end of history \u2014';\n log.insertBefore(marker, log.firstChild);\n }\n }\n } finally {\n historyLoading = false;\n }\n}\n\nloadHistoryPage();\nlog.addEventListener('scroll', () => {\n if (log.scrollTop < 80) loadHistoryPage();\n});\n\n// \u2500\u2500 mascot crossfade on mood event \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst mascotEl = document.getElementById('mascot');\nconst mascotTagEl = document.getElementById('mascotTag');\nlet currentMood = 'neutral';\nfunction setMood(slug) {\n if (!slug || slug === currentMood) return;\n const url = '/assets/lisa/' + encodeURIComponent(slug) + '.png';\n const probe = new Image();\n probe.onload = () => {\n mascotEl.classList.add('fading');\n setTimeout(() => {\n mascotEl.src = url;\n mascotTagEl.textContent = 'mood: ' + slug;\n mascotEl.classList.remove('fading');\n currentMood = slug;\n }, 250);\n };\n probe.onerror = () => { /* asset not generated yet \u2014 keep current */ };\n probe.src = url;\n}\n\n// \u2500\u2500 modal panel: SOUL / SKILLS / MEMORY / TOOLS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst modalBg = document.getElementById('modalBg');\nconst modalTitle = document.getElementById('modalTitle');\nconst modalBody = document.getElementById('modalBody');\nconst modalClose = document.getElementById('modalClose');\n\nfunction escapeHtml(s) {\n return String(s).replace(/[&<>\"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n}\nfunction openModal(title, html) {\n modalTitle.textContent = title;\n modalBody.innerHTML = html;\n modalBg.classList.add('open');\n}\nfunction closeModal() { modalBg.classList.remove('open'); }\nmodalClose.addEventListener('click', closeModal);\nmodalBg.addEventListener('click', (e) => { if (e.target === modalBg) closeModal(); });\ndocument.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });\n\nasync function showSkills() {\n openModal('SKILLS', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/skills').then(r => r.json());\n if (!data.skills.length) {\n modalBody.innerHTML = '<div class=\"empty\">No skills saved yet. Lisa will start saving useful workflows as you use her.</div>';\n return;\n }\n modalBody.innerHTML = data.skills.map(s =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(s.name) + '</div><div class=\"desc\">' + escapeHtml(s.description) + '</div></div>'\n ).join('');\n}\n\nasync function showMemory() {\n openModal('MEMORY', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/memory').then(r => r.json());\n modalBody.innerHTML =\n '<h3>USER.md \u2014 what Lisa remembers about you</h3>' +\n '<pre>' + escapeHtml(data.user || '(empty)') + '</pre>' +\n '<h3>MEMORY.md \u2014 Lisa\\'s working notes</h3>' +\n '<pre>' + escapeHtml(data.memory || '(empty)') + '</pre>';\n}\n\nasync function showTools() {\n openModal('TOOLS', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/tools').then(r => r.json());\n modalBody.innerHTML = data.tools.map(t =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(t.name) + '</div><div class=\"desc\">' + escapeHtml(t.description) + '</div></div>'\n ).join('');\n}\n\nasync function showSoul() {\n openModal('\u2605 SOUL', '<div class=\"empty\">loading\u2026</div>');\n const data = await fetch('/api/soul').then(r => r.json());\n if (!data.born) {\n modalBody.innerHTML = '<div class=\"empty\">Lisa hasn\\'t been born yet. Restart the CLI without --no-birth and the birth ritual will run.</div>';\n return;\n }\n const s = data.summary;\n let html = '';\n html += '<h3>name</h3><div>' + escapeHtml(s.name) + '</div>';\n html += '<h3>born</h3><div>' + escapeHtml(s.seed.bornAt) + ' \u00B7 big5(O' + Math.round(s.seed.bigFive.openness*100) + ' C' + Math.round(s.seed.bigFive.conscientiousness*100) + ' E' + Math.round(s.seed.bigFive.extraversion*100) + ' A' + Math.round(s.seed.bigFive.agreeableness*100) + ' N' + Math.round(s.seed.bigFive.neuroticism*100) + ')</div>';\n html += '<h3>identity</h3><pre>' + escapeHtml(s.identity) + '</pre>';\n html += '<h3>purpose</h3><pre>' + escapeHtml(s.purpose) + '</pre>';\n html += '<h3>constitution</h3><pre>' + escapeHtml(s.constitution) + '</pre>';\n if (s.values?.length) {\n html += '<h3>values</h3>' + s.values.map(v =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(v.title) + '</div><div class=\"desc\">' + escapeHtml(v.body) + '</div></div>'\n ).join('');\n }\n if (s.opinions?.length) {\n html += '<h3>opinions</h3>' + s.opinions.map(o =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(o.stance) + ' (conf ' + o.confidence.toFixed(2) + ')</div></div>'\n ).join('');\n }\n if (s.desires?.length) {\n html += '<h3>desires</h3>' + s.desires.map(d =>\n '<div class=\"item\"><div class=\"name\">' + escapeHtml(d.what) + (d.actionable ? ' [heartbeat-active]' : '') + '</div><div class=\"desc\">' + escapeHtml(d.why) + '</div></div>'\n ).join('');\n }\n html += '<h3>emotions</h3>' + Object.entries(s.emotions.values).map(([k, v]) => {\n const len = 12;\n const filled = Math.round(Math.abs(v) * len);\n const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(len - filled);\n return '<div class=\"item\"><div class=\"name\">' + escapeHtml(k) + '</div><div class=\"desc\">' + (v < 0 ? '-' : ' ') + bar + ' ' + v.toFixed(2) + '</div></div>';\n }).join('');\n if (s.tampered?.length) {\n html += '<h3>\u26A0 tampered</h3><div>External edits detected on: ' + s.tampered.map(escapeHtml).join(', ') + '</div>';\n }\n html += '<h3 style=\"color: var(--fg-3); font-size: 10px;\">privacy note</h3><div class=\"empty\">Her journal lives at ~/.lisa/soul/journal/ but is intentionally not shown here \u2014 that is hers to keep.</div>';\n modalBody.innerHTML = html;\n}\n\ndocument.querySelectorAll('.badge').forEach(b => {\n b.addEventListener('click', () => {\n const which = b.dataset.panel;\n if (which === 'soul') showSoul();\n else if (which === 'skills') showSkills();\n else if (which === 'memory') showMemory();\n else if (which === 'tools') showTools();\n });\n});\n\nlet currentLisaSpan = null;\nlet pendingTools = new Map();\nlet thinkingEl = null;\n\nfunction el(tag, cls, text) {\n const node = document.createElement(tag);\n if (cls) node.className = cls;\n if (text != null) node.textContent = text;\n log.appendChild(node);\n log.scrollTop = log.scrollHeight;\n return node;\n}\n\nfunction ensureLisaSpan() {\n if (currentLisaSpan) return currentLisaSpan;\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n el('div', 'role lisa', 'LISA');\n currentLisaSpan = el('span', 'msg', '');\n return currentLisaSpan;\n}\n\nfunction previewInput(name, input) {\n if (!input || typeof input !== 'object') return '';\n const order = ['command', 'pattern', 'query', 'path', 'description', 'audio_path', 'text', 'name', 'action', 'entry'];\n for (const k of order) {\n if (typeof input[k] === 'string' && input[k]) {\n let v = input[k].replace(/\\s+/g, ' ').trim();\n if (v.length > 120) v = v.slice(0, 117) + '...';\n return v;\n }\n }\n try {\n const s = JSON.stringify(input);\n return s.length > 120 ? s.slice(0, 117) + '...' : s;\n } catch { return ''; }\n}\n\nasync function send(message) {\n input.value = '';\n input.style.height = 'auto';\n sendBtn.disabled = true;\n el('div', 'role you', 'YOU');\n el('span', 'msg', message || '(attachment)');\n if (pendingFiles.length) {\n const names = pendingFiles.map(f => f.name).join(', ');\n el('span', 'msg attach-label', '\uD83D\uDCCE ' + names);\n }\n const filesToSend = [...pendingFiles];\n pendingFiles = [];\n renderAttachPreview();\n currentLisaSpan = null;\n pendingTools.clear();\n thinkingEl = el('div', 'thinking', '\u22EF thinking');\n try {\n const res = await fetch('/chat', {\n method: 'POST',\n headers: {'content-type': 'application/json'},\n body: JSON.stringify({message, files: filesToSend}),\n });\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n while (true) {\n const {value, done} = await reader.read();\n if (done) break;\n buf += decoder.decode(value, {stream: true});\n let idx;\n while ((idx = buf.indexOf('\\n\\n')) >= 0) {\n const evRaw = buf.slice(0, idx);\n buf = buf.slice(idx + 2);\n const m = evRaw.match(/^data: (.*)$/m);\n if (!m) continue;\n const ev = JSON.parse(m[1]);\n if (ev.type === 'text') {\n ensureLisaSpan().textContent += ev.text;\n } else if (ev.type === 'tool_start') {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n currentLisaSpan = null;\n const block = el('div', 'tool-block', null);\n const head = document.createElement('div');\n head.className = 'tool-head';\n head.innerHTML = '<span class=\"tool-icon\">\u2699</span> <span class=\"tool-name\"></span> <span class=\"tool-spinner\">...</span>';\n head.querySelector('.tool-name').textContent = ev.name;\n block.appendChild(head);\n const preview = previewInput(ev.name, ev.input);\n if (preview) {\n const p = document.createElement('div');\n p.className = 'tool-input';\n p.textContent = preview;\n block.appendChild(p);\n }\n pendingTools.set(ev.name, block);\n } else if (ev.type === 'tool_end') {\n const block = pendingTools.get(ev.name);\n if (block) {\n const spinner = block.querySelector('.tool-spinner');\n if (spinner) spinner.textContent = ev.isError ? '\u2717' : '\u2713';\n if (ev.isError) block.classList.add('tool-error');\n if (ev.resultPreview) {\n const r = document.createElement('div');\n r.className = 'tool-result';\n r.textContent = ev.resultPreview;\n block.appendChild(r);\n }\n pendingTools.delete(ev.name);\n }\n } else if (ev.type === 'mood') {\n setMood(ev.slug);\n } else if (ev.type === 'error') {\n el('div', 'err', '[error] ' + ev.message);\n } else if (ev.type === 'done') {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n }\n }\n }\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n } catch (err) {\n if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }\n el('div', 'err', '[error] ' + err.message);\n } finally {\n sendBtn.disabled = false;\n input.focus();\n }\n}\n\nform.addEventListener('submit', (ev) => {\n ev.preventDefault();\n const msg = input.value.trim();\n if (msg || pendingFiles.length) send(msg);\n});\n\ninput.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter' && !ev.shiftKey) {\n ev.preventDefault();\n form.dispatchEvent(new Event('submit'));\n }\n});\ninput.addEventListener('input', () => {\n input.style.height = 'auto';\n input.style.height = Math.min(input.scrollHeight, 200) + 'px';\n});\n\n// \u2500\u2500 PWA: register service worker + iOS install hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/sw.js').catch(err => {\n console.warn('[pwa] sw register failed:', err);\n });\n}\n(function() {\n const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent);\n const isStandalone = window.matchMedia('(display-mode: standalone)').matches\n || (window.navigator).standalone === true;\n if (!isiOS || isStandalone) return;\n if (localStorage.getItem('lisa.pwa.dismissed') === '1') return;\n setTimeout(() => {\n const banner = document.createElement('div');\n banner.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;background:var(--bg-card-strong);border:1px solid var(--border-strong);border-radius:12px;padding:10px 12px;color:var(--fg);font-size:12px;z-index:9999;display:flex;gap:8px;align-items:center;';\n banner.innerHTML = '\u2726 Add Lisa to Home Screen: Share button \u2192 \"Add to Home Screen\"';\n const dismiss = document.createElement('button');\n dismiss.textContent = '\u2715';\n dismiss.style.cssText = 'background:transparent;border:none;color:var(--fg-2);cursor:pointer;font-size:14px;margin-left:auto;';\n dismiss.onclick = () => {\n localStorage.setItem('lisa.pwa.dismissed', '1');\n banner.remove();\n };\n banner.appendChild(dismiss);\n document.body.appendChild(banner);\n }, 5000);\n})();\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \u2500\u2500 Sidebar live wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Populates the new sidebar blocks introduced by the redesign:\n// - identity card sub-line (born YYYY-MM-DD \u00B7 NN days)\n// - \"currently wanting\" paragraph (top actionable desire)\n// - Claude Code monitor card (active sessions)\n// - \"last reflection\" mini-card (most recent \u2605 idle message)\n// Wires to /api/island/ping + /api/claude/sessions + /api/soul, and\n// piggy-backs on the connectEvents() SSE listener above for live\n// claude_session_update + idle_message refreshes.\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n(function setupSidebarLive() {\n const sbDesire = document.getElementById('sbDesire');\n const sbClaudeCount = document.getElementById('sbClaudeCount');\n const sbClaudeRows = document.getElementById('sbClaudeRows');\n const sbReflection = document.getElementById('sbReflection');\n const sbReflectionBody = document.getElementById('sbReflectionBody');\n const sbSessionBadge = document.getElementById('sbSessionBadge');\n const identitySub = document.getElementById('identitySub');\n\n // Active session window matches the watcher's ACTIVE_WINDOW_MS.\n const ACTIVE_WINDOW_MS = 30 * 60 * 1000;\n\n function relativeTime(iso) {\n const ms = Date.now() - new Date(iso).getTime();\n if (ms < 30_000) return 'just now';\n if (ms < 60_000) return Math.round(ms / 1000) + 's';\n if (ms < 3600_000) return Math.round(ms / 60_000) + 'm';\n return Math.round(ms / 3600_000) + 'h';\n }\n\n function setDesire(text) {\n sbDesire.textContent = text || '(nothing actively pursued)';\n }\n\n window.updateReflection = function (text) {\n if (!text) { sbReflection.style.display = 'none'; return; }\n sbReflection.style.display = '';\n sbReflectionBody.textContent = '\"' + text.replace(/^[\"\u201C\u201D]+|[\"\u201C\u201D]+$/g, '').trim() + '\"';\n };\n\n function setClaudeSessions(sessions) {\n const cutoff = Date.now() - ACTIVE_WINDOW_MS;\n const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff);\n sbClaudeCount.textContent = String(recent.length);\n // sort: errors first, then waiting, then working, then by mtime\n const rank = { error: 0, waiting: 1, working: 2, unknown: 3 };\n const rows = recent.slice().sort((a, b) => {\n const ra = rank[a.state] ?? 9;\n const rb = rank[b.state] ?? 9;\n if (ra !== rb) return ra - rb;\n return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();\n }).slice(0, 5);\n while (sbClaudeRows.firstChild) sbClaudeRows.removeChild(sbClaudeRows.firstChild);\n if (rows.length === 0) {\n const empty = document.createElement('div');\n empty.className = 'session-empty';\n empty.textContent = '(idle)';\n sbClaudeRows.appendChild(empty);\n return;\n }\n for (const s of rows) {\n const row = document.createElement('div');\n row.className = 'session-row';\n const pip = document.createElement('div');\n pip.className = 'pip ' + (s.state || 'unknown');\n const name = document.createElement('div');\n name.className = 'name';\n name.textContent = s.project;\n const when = document.createElement('div');\n when.className = 'when';\n when.textContent = relativeTime(s.lastMtime);\n row.appendChild(pip);\n row.appendChild(name);\n row.appendChild(when);\n row.title = (s.stateReason ? s.state + ' \u00B7 ' + s.stateReason : s.state) + ' \u00B7 ' + s.sessionId;\n sbClaudeRows.appendChild(row);\n }\n }\n\n async function refreshPing() {\n try {\n const r = await fetch('/api/island/ping');\n if (!r.ok) return;\n const data = await r.json();\n setDesire(data.current_desire);\n if (data.last_idle_message_text) {\n window.updateReflection(data.last_idle_message_text);\n }\n } catch {}\n }\n\n // Exposed so the SSE handler above can call this on\n // claude_session_update events without redeclaring the helper.\n window.refreshClaudeSessions = async function () {\n try {\n const r = await fetch('/api/claude/sessions');\n if (!r.ok) return;\n const data = await r.json();\n setClaudeSessions(data.sessions || []);\n } catch {}\n };\n\n async function refreshIdentity() {\n try {\n const r = await fetch('/api/soul');\n if (!r.ok) return;\n const data = await r.json();\n if (!data.born) return;\n const bornAt = data.summary?.seed?.bornAt;\n if (!bornAt) return;\n const born = new Date(bornAt);\n if (Number.isNaN(born.getTime())) return;\n const days = Math.max(0, Math.floor((Date.now() - born.getTime()) / 86400000));\n const ymd = born.toISOString().slice(0, 10);\n identitySub.textContent = 'born ' + ymd + ' \u00B7 ' + days + ' day' + (days === 1 ? '' : 's');\n } catch {}\n }\n\n async function refreshSessionsBadge() {\n try {\n const r = await fetch('/api/sessions');\n if (!r.ok) return;\n const data = await r.json();\n const n = Array.isArray(data.sessions) ? data.sessions.length : 0;\n sbSessionBadge.textContent = String(n);\n } catch {\n // /api/sessions is optional \u2014 leave the badge as-is on failure\n }\n }\n\n // Bootstrap + periodic resync. SSE handles the fast-path updates;\n // these timers are belt-and-braces in case the stream silently dies.\n refreshPing();\n window.refreshClaudeSessions();\n refreshIdentity();\n refreshSessionsBadge();\n setInterval(refreshPing, 30_000);\n setInterval(window.refreshClaudeSessions, 60_000);\n setInterval(refreshSessionsBadge, 5 * 60_000);\n})();\n</script>\n</body></html>";
29
+ //# sourceMappingURL=lisa-html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lisa-html.d.ts","sourceRoot":"","sources":["../../src/web/lisa-html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,eAAO,MAAM,SAAS,+ttEAkhEP,CAAC"}