@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
@@ -13,1607 +13,12 @@ import { readActiveWebSession, writeActiveWebSession, } from "../sessions/active
13
13
  import { listSessionsOnDisk } from "../sessions/list.js";
14
14
  import { SessionStore } from "../sessions/store.js";
15
15
  import { reflectOnSession } from "../reflect.js";
16
+ import { listDesires } from "../soul/store.js";
17
+ import { ISLAND_HTML } from "./island.js";
18
+ import { MAIN_HTML } from "./lisa-html.js";
19
+ import { ClaudeCodeWatcher } from "../integrations/claude-code/watcher.js";
16
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
21
  const ASSETS_DIR = path.join(__dirname, "assets");
18
- const HTML = `<!doctype html>
19
- <html lang="en"><head>
20
- <meta charset="utf-8">
21
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
22
- <title>LISA</title>
23
- <!-- PWA manifest + theming. Lets users add Lisa to their home screen on
24
- iOS Safari / Android Chrome and run her as a standalone app shell. -->
25
- <link rel="manifest" href="/manifest.webmanifest">
26
- <meta name="theme-color" content="#0a0d2b">
27
- <meta name="apple-mobile-web-app-capable" content="yes">
28
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
29
- <meta name="apple-mobile-web-app-title" content="LISA">
30
- <link rel="apple-touch-icon" href="/assets/lisa-mascot.png">
31
- <link rel="icon" type="image/png" href="/assets/lisa-mascot.png">
32
- <link rel="preconnect" href="https://fonts.googleapis.com">
33
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
34
- <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
35
- <style>
36
- :root {
37
- --bg: #0a0d2b;
38
- --panel: #1a1f4d;
39
- --panel-light: #2a3270;
40
- --border: #6a7ad9;
41
- --border-light: #a4b2ff;
42
- --text: #e7ecff;
43
- --text-dim: #8090c0;
44
- --you: #6cf6e1;
45
- --lisa: #ffd167;
46
- --tool: #ff7eb6;
47
- --error: #ff5c5c;
48
- }
49
- * { box-sizing: border-box; }
50
- html, body { height: 100%; margin: 0; }
51
- body {
52
- font-family: 'VT323', monospace;
53
- font-size: 22px;
54
- line-height: 1.25;
55
- color: var(--text);
56
- background-color: var(--bg);
57
- background-image: url('/assets/background-tile.png');
58
- background-repeat: repeat;
59
- background-size: 256px 256px;
60
- image-rendering: pixelated;
61
- overflow: hidden;
62
- }
63
- /* CRT scanlines + vignette */
64
- body::before {
65
- content: '';
66
- position: fixed;
67
- inset: 0;
68
- pointer-events: none;
69
- background:
70
- repeating-linear-gradient(
71
- to bottom,
72
- rgba(0,0,0,0) 0,
73
- rgba(0,0,0,0) 2px,
74
- rgba(0,0,0,0.18) 3px,
75
- rgba(0,0,0,0) 4px
76
- ),
77
- radial-gradient(ellipse at center, rgba(0,0,0,0) 60%, rgba(0,0,0,0.45) 100%);
78
- z-index: 1000;
79
- }
80
-
81
- .frame {
82
- display: grid;
83
- grid-template-columns: 280px 1fr;
84
- grid-template-rows: 64px 1fr 80px;
85
- grid-template-areas:
86
- "header header"
87
- "side chat"
88
- "side input";
89
- height: 100vh;
90
- padding: 20px;
91
- gap: 16px;
92
- }
93
-
94
- /* Reusable pixel-art bordered panel — chunky 4px outline + inset highlight */
95
- .panel {
96
- background: var(--panel);
97
- border: 4px solid var(--border);
98
- box-shadow:
99
- inset 2px 2px 0 var(--border-light),
100
- inset -2px -2px 0 #000,
101
- 0 0 0 2px #000;
102
- image-rendering: pixelated;
103
- }
104
-
105
- header.panel {
106
- grid-area: header;
107
- display: flex;
108
- align-items: center;
109
- justify-content: space-between;
110
- padding: 0 16px;
111
- }
112
- .logo {
113
- font-family: 'Press Start 2P', monospace;
114
- font-size: 16px;
115
- color: var(--lisa);
116
- letter-spacing: 4px;
117
- text-shadow: 2px 2px 0 #000;
118
- }
119
- .logo .star { color: var(--you); animation: pulse 1s steps(2) infinite; }
120
- @keyframes pulse { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0.3; } }
121
- .badges {
122
- display: flex;
123
- gap: 14px;
124
- align-items: center;
125
- font-family: 'Press Start 2P', monospace;
126
- font-size: 9px;
127
- color: var(--text-dim);
128
- }
129
- .badge {
130
- display: flex;
131
- align-items: center;
132
- gap: 6px;
133
- background: transparent;
134
- border: 2px solid transparent;
135
- color: var(--text-dim);
136
- font-family: 'Press Start 2P', monospace;
137
- font-size: 9px;
138
- padding: 4px 8px;
139
- cursor: pointer;
140
- image-rendering: pixelated;
141
- box-shadow: none;
142
- }
143
- .badge:hover {
144
- color: var(--lisa);
145
- background: rgba(255, 209, 103, 0.1);
146
- border-color: var(--lisa);
147
- }
148
- .badge:active { transform: translate(1px, 1px); }
149
- .badge img { width: 24px; height: 24px; image-rendering: pixelated; }
150
-
151
- /* Modal panel for skills / memory / tools */
152
- .modal-bg {
153
- position: fixed; inset: 0;
154
- background: rgba(0, 0, 0, 0.7);
155
- display: none;
156
- align-items: center; justify-content: center;
157
- z-index: 100;
158
- }
159
- .modal-bg.open { display: flex; }
160
- .modal {
161
- background: var(--panel);
162
- border: 4px solid var(--border);
163
- box-shadow: inset 2px 2px 0 var(--border-light), inset -2px -2px 0 #000, 0 0 0 2px #000;
164
- max-width: 720px;
165
- width: 90vw;
166
- max-height: 80vh;
167
- display: flex;
168
- flex-direction: column;
169
- image-rendering: pixelated;
170
- }
171
- .modal-head {
172
- padding: 12px 16px;
173
- border-bottom: 2px solid var(--border);
174
- display: flex;
175
- justify-content: space-between;
176
- align-items: center;
177
- }
178
- .modal-title {
179
- font-family: 'Press Start 2P', monospace;
180
- font-size: 14px;
181
- color: var(--lisa);
182
- text-shadow: 2px 2px 0 #000;
183
- }
184
- .modal-close {
185
- background: var(--panel-light);
186
- border: 3px solid var(--border-light);
187
- box-shadow: inset 1px 1px 0 #fff5, inset -1px -1px 0 #0008;
188
- color: var(--text);
189
- font-family: 'Press Start 2P', monospace;
190
- font-size: 10px;
191
- padding: 6px 10px;
192
- cursor: pointer;
193
- }
194
- .modal-close:hover { background: var(--error); color: #fff; }
195
- .modal-body {
196
- padding: 16px;
197
- overflow-y: auto;
198
- font-family: 'VT323', monospace;
199
- font-size: 18px;
200
- line-height: 1.4;
201
- }
202
- .modal-body h3 {
203
- font-family: 'Press Start 2P', monospace;
204
- font-size: 11px;
205
- color: var(--you);
206
- margin: 12px 0 6px 0;
207
- border-bottom: 1px dashed var(--border);
208
- padding-bottom: 4px;
209
- }
210
- .modal-body h3:first-child { margin-top: 0; }
211
- .modal-body .item {
212
- padding: 6px 0;
213
- border-bottom: 1px dotted #ffffff15;
214
- }
215
- .modal-body .item:last-child { border: none; }
216
- .modal-body .name {
217
- color: var(--lisa);
218
- font-family: 'Press Start 2P', monospace;
219
- font-size: 10px;
220
- }
221
- .modal-body .desc { color: var(--text-dim); }
222
- .modal-body pre {
223
- background: #00000040;
224
- padding: 8px;
225
- border-left: 2px solid var(--border);
226
- white-space: pre-wrap;
227
- margin: 4px 0;
228
- color: var(--text);
229
- }
230
- .modal-body .empty { color: var(--text-dim); font-style: italic; }
231
-
232
- aside.panel {
233
- grid-area: side;
234
- display: flex;
235
- flex-direction: column;
236
- align-items: center;
237
- padding: 16px;
238
- gap: 12px;
239
- }
240
- .mascot-frame {
241
- width: 100%;
242
- aspect-ratio: 1;
243
- position: relative;
244
- border: 4px solid var(--border-light);
245
- background: linear-gradient(180deg, #2a3270 0%, #1a1f4d 100%);
246
- box-shadow: inset 0 0 0 2px #000, 0 0 0 2px #000;
247
- overflow: hidden;
248
- }
249
- .mascot {
250
- position: absolute;
251
- inset: 0;
252
- width: 100%;
253
- height: 100%;
254
- image-rendering: pixelated;
255
- object-fit: contain;
256
- transition: opacity 0.25s steps(4);
257
- }
258
- .mascot.fading { opacity: 0; }
259
- .mascot-tag {
260
- position: absolute;
261
- bottom: 4px;
262
- left: 4px;
263
- right: 4px;
264
- text-align: center;
265
- font-family: 'Press Start 2P', monospace;
266
- font-size: 8px;
267
- color: var(--lisa);
268
- text-shadow: 1px 1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000, -1px -1px 0 #000;
269
- background: rgba(10, 13, 43, 0.6);
270
- padding: 3px;
271
- pointer-events: none;
272
- }
273
- .name {
274
- font-family: 'Press Start 2P', monospace;
275
- font-size: 16px;
276
- color: var(--lisa);
277
- text-shadow: 2px 2px 0 #000;
278
- }
279
- .status {
280
- font-size: 18px;
281
- color: var(--you);
282
- }
283
- .status .dot {
284
- display: inline-block;
285
- width: 10px;
286
- height: 10px;
287
- background: var(--you);
288
- margin-right: 6px;
289
- box-shadow: 0 0 6px var(--you);
290
- animation: pulse 1s steps(2) infinite;
291
- }
292
- .session-id {
293
- margin-top: auto;
294
- font-size: 14px;
295
- color: var(--text-dim);
296
- word-break: break-all;
297
- text-align: center;
298
- }
299
-
300
- main.panel {
301
- grid-area: chat;
302
- overflow: hidden;
303
- display: flex;
304
- flex-direction: column;
305
- padding: 16px;
306
- }
307
- #log {
308
- flex: 1;
309
- overflow-y: auto;
310
- white-space: pre-wrap;
311
- word-break: break-word;
312
- padding-right: 8px;
313
- }
314
- #log::-webkit-scrollbar { width: 12px; }
315
- #log::-webkit-scrollbar-track { background: var(--panel); }
316
- #log::-webkit-scrollbar-thumb { background: var(--border); border: 2px solid var(--panel); }
317
-
318
- .role {
319
- font-family: 'Press Start 2P', monospace;
320
- font-size: 11px;
321
- margin-top: 12px;
322
- margin-bottom: 4px;
323
- text-shadow: 1px 1px 0 #000;
324
- }
325
- .role.you { color: var(--you); }
326
- .role.lisa { color: var(--lisa); }
327
- .tool-block {
328
- margin: 8px 0;
329
- padding: 8px 12px;
330
- background: rgba(255, 126, 182, 0.08);
331
- border-left: 4px solid var(--tool);
332
- box-shadow: inset 1px 1px 0 #0006;
333
- font-size: 18px;
334
- }
335
- .tool-block.tool-error {
336
- background: rgba(255, 92, 92, 0.12);
337
- border-left-color: var(--error);
338
- }
339
- .tool-head {
340
- color: var(--tool);
341
- font-family: 'Press Start 2P', monospace;
342
- font-size: 11px;
343
- display: flex;
344
- gap: 8px;
345
- align-items: center;
346
- }
347
- .tool-block.tool-error .tool-head { color: var(--error); }
348
- .tool-icon { font-size: 14px; }
349
- .tool-spinner { color: var(--text-dim); font-size: 14px; }
350
- .tool-input {
351
- color: var(--text);
352
- margin-top: 4px;
353
- padding-left: 22px;
354
- font-family: 'VT323', monospace;
355
- word-break: break-all;
356
- }
357
- .tool-result {
358
- color: var(--text-dim);
359
- margin-top: 6px;
360
- padding-left: 22px;
361
- font-size: 16px;
362
- max-height: 100px;
363
- overflow: hidden;
364
- white-space: pre-wrap;
365
- word-break: break-word;
366
- font-family: 'VT323', monospace;
367
- border-top: 1px dashed var(--border);
368
- padding-top: 4px;
369
- }
370
- .thinking {
371
- color: var(--text-dim);
372
- font-style: italic;
373
- margin-top: 6px;
374
- animation: pulse 1.2s steps(3) infinite;
375
- }
376
- .err { color: var(--error); margin-top: 6px; }
377
- .msg { display: block; }
378
- @keyframes blink { 50% { opacity: 0; } }
379
-
380
- .idle-block {
381
- margin: 14px 0;
382
- padding: 10px 12px;
383
- background: rgba(108, 246, 225, 0.06);
384
- border-left: 3px solid var(--you);
385
- border-radius: 0;
386
- font-size: 19px;
387
- color: var(--text);
388
- font-family: 'VT323', monospace;
389
- animation: idleFade 0.6s ease-out;
390
- }
391
- @keyframes idleFade {
392
- from { opacity: 0; transform: translateY(-4px); }
393
- to { opacity: 1; transform: translateY(0); }
394
- }
395
- .idle-block .idle-head {
396
- color: var(--you);
397
- font-family: 'Press Start 2P', monospace;
398
- font-size: 9px;
399
- margin-bottom: 6px;
400
- letter-spacing: 2px;
401
- }
402
- .idle-block .idle-time {
403
- color: var(--text-dim);
404
- font-size: 13px;
405
- margin-left: 6px;
406
- }
407
- .idle-pulse {
408
- color: var(--text-dim);
409
- font-style: italic;
410
- margin: 8px 0;
411
- animation: pulse 1.5s steps(3) infinite;
412
- }
413
-
414
- #attachPreview {
415
- grid-area: input;
416
- display: flex;
417
- flex-wrap: wrap;
418
- gap: 6px;
419
- padding: 0 4px 4px;
420
- min-height: 0;
421
- }
422
- #attachPreview:empty { display: none; }
423
- .attach-chip {
424
- background: var(--panel-light);
425
- border: 2px solid var(--border);
426
- color: var(--text);
427
- font-size: 9px;
428
- padding: 3px 6px;
429
- display: flex;
430
- align-items: center;
431
- gap: 4px;
432
- font-family: 'Press Start 2P', monospace;
433
- }
434
- .attach-rm {
435
- background: none;
436
- border: none;
437
- color: var(--you);
438
- cursor: pointer;
439
- font-size: 11px;
440
- padding: 0;
441
- line-height: 1;
442
- box-shadow: none;
443
- font-family: inherit;
444
- }
445
- .attach-rm:hover { background: none; color: #f55; }
446
- #attachBtn {
447
- display: flex;
448
- align-items: center;
449
- justify-content: center;
450
- font-size: 20px;
451
- cursor: pointer;
452
- padding: 4px 8px;
453
- background: var(--panel);
454
- border: 4px solid var(--border);
455
- box-shadow:
456
- inset 2px 2px 0 #000,
457
- inset -2px -2px 0 var(--border-light),
458
- 0 0 0 2px #000;
459
- user-select: none;
460
- flex-shrink: 0;
461
- }
462
- #attachBtn:hover { background: var(--border); }
463
- .attach-label { font-size: 9px; opacity: 0.7; margin-left: 4px; }
464
- form#form {
465
- grid-area: input;
466
- display: grid;
467
- grid-template-columns: auto 1fr 120px;
468
- gap: 16px;
469
- align-items: start;
470
- }
471
- textarea {
472
- background: var(--panel);
473
- border: 4px solid var(--border);
474
- box-shadow:
475
- inset 2px 2px 0 #000,
476
- inset -2px -2px 0 var(--border-light),
477
- 0 0 0 2px #000;
478
- color: var(--text);
479
- font: inherit;
480
- padding: 12px;
481
- resize: none;
482
- image-rendering: pixelated;
483
- }
484
- textarea:focus {
485
- outline: none;
486
- border-color: var(--you);
487
- }
488
- button {
489
- background: var(--panel-light);
490
- border: 4px solid var(--border-light);
491
- box-shadow:
492
- inset 2px 2px 0 #fff5,
493
- inset -2px -2px 0 #0008,
494
- 0 0 0 2px #000;
495
- color: var(--text);
496
- font-family: 'Press Start 2P', monospace;
497
- font-size: 12px;
498
- cursor: pointer;
499
- display: flex;
500
- flex-direction: column;
501
- align-items: center;
502
- justify-content: center;
503
- gap: 6px;
504
- padding: 8px;
505
- image-rendering: pixelated;
506
- transition: transform 0.05s steps(1);
507
- }
508
- button img { width: 36px; height: 36px; image-rendering: pixelated; }
509
- button:hover {
510
- background: var(--border);
511
- color: #000;
512
- }
513
- button:active {
514
- transform: translate(2px, 2px);
515
- box-shadow:
516
- inset 2px 2px 0 #0008,
517
- inset -2px -2px 0 #fff5,
518
- 0 0 0 2px #000;
519
- }
520
- button:disabled {
521
- opacity: 0.5;
522
- cursor: not-allowed;
523
- }
524
-
525
- /* Mobile + PWA standalone */
526
- @media (max-width: 720px) {
527
- body {
528
- /* Honor iOS safe area when running standalone (notch / home indicator). */
529
- padding-top: env(safe-area-inset-top);
530
- padding-bottom: env(safe-area-inset-bottom);
531
- }
532
- .frame {
533
- grid-template-columns: 1fr;
534
- grid-template-rows: 56px auto 1fr auto;
535
- grid-template-areas: "header" "side" "chat" "input";
536
- /* Account for safe areas inside the frame too, so input never hides
537
- behind iOS home indicator. */
538
- min-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
539
- }
540
- aside.panel {
541
- flex-direction: row;
542
- flex-wrap: wrap;
543
- gap: 8px;
544
- padding: 8px;
545
- }
546
- .mascot { width: 80px; }
547
- /* Inputs grow with content on mobile; keep cap so it doesn't cover chat. */
548
- .input-area textarea {
549
- min-height: 44px;
550
- max-height: 35vh;
551
- font-size: 16px; /* prevents iOS Safari auto-zoom on focus */
552
- }
553
- /* Chat scroll area uses dynamic viewport height to handle the
554
- ever-changing iOS Safari toolbar without the bottom getting cut off. */
555
- .chat {
556
- max-height: calc(100dvh - 240px);
557
- }
558
- /* Header inspector buttons get tighter on narrow screens. */
559
- .badge { padding: 4px 6px; font-size: 10px; }
560
- .badge img { width: 14px; height: 14px; }
561
- }
562
- /* PWA standalone-specific tweaks (also fires on installed Lisa). */
563
- @media (display-mode: standalone) {
564
- body { background: var(--bg); }
565
- }
566
-
567
- /* ── Birth ritual full-screen overlay ──────────────────────────────── */
568
- .birth-overlay {
569
- position: fixed;
570
- inset: 0;
571
- background:
572
- radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%),
573
- url('/assets/background-tile.png');
574
- background-size: cover, 256px 256px;
575
- background-repeat: no-repeat, repeat;
576
- z-index: 9999;
577
- display: none;
578
- flex-direction: column;
579
- align-items: center;
580
- justify-content: center;
581
- padding: 32px;
582
- image-rendering: pixelated;
583
- }
584
- .birth-overlay.open { display: flex; }
585
- .birth-overlay::before {
586
- content: '';
587
- position: absolute;
588
- inset: 0;
589
- pointer-events: none;
590
- background: repeating-linear-gradient(
591
- to bottom,
592
- rgba(0,0,0,0) 0,
593
- rgba(0,0,0,0) 2px,
594
- rgba(0,0,0,0.25) 3px,
595
- rgba(0,0,0,0) 4px
596
- );
597
- z-index: 1;
598
- }
599
- .birth-content {
600
- position: relative;
601
- z-index: 2;
602
- width: min(800px, 95vw);
603
- max-height: 90vh;
604
- overflow-y: auto;
605
- }
606
- .birth-stars {
607
- text-align: center;
608
- color: var(--lisa);
609
- font-family: 'Press Start 2P', monospace;
610
- font-size: 14px;
611
- letter-spacing: 8px;
612
- text-shadow: 0 0 8px var(--lisa);
613
- animation: starBlink 1.5s steps(3) infinite;
614
- }
615
- @keyframes starBlink {
616
- 0%, 30%, 100% { opacity: 1; }
617
- 50% { opacity: 0.4; }
618
- }
619
- .birth-title {
620
- text-align: center;
621
- color: var(--you);
622
- font-family: 'Press Start 2P', monospace;
623
- font-size: 22px;
624
- letter-spacing: 6px;
625
- margin: 24px 0 32px;
626
- text-shadow: 2px 2px 0 #000, 0 0 12px var(--you);
627
- }
628
- .birth-step {
629
- margin: 14px 0;
630
- padding: 10px 16px;
631
- border-left: 3px solid var(--border);
632
- background: rgba(20, 20, 50, 0.5);
633
- opacity: 0;
634
- transform: translateY(8px);
635
- transition: opacity 0.4s ease-out, transform 0.4s ease-out;
636
- }
637
- .birth-step.shown {
638
- opacity: 1;
639
- transform: translateY(0);
640
- }
641
- .birth-step.active {
642
- border-left-color: var(--lisa);
643
- background: rgba(255, 209, 103, 0.08);
644
- animation: stepGlow 1.2s ease-in-out infinite alternate;
645
- }
646
- @keyframes stepGlow {
647
- from { box-shadow: inset 2px 0 0 var(--lisa); }
648
- to { box-shadow: inset 6px 0 12px var(--lisa); }
649
- }
650
- .birth-step.done { border-left-color: var(--you); }
651
- .birth-step .step-name {
652
- font-family: 'Press Start 2P', monospace;
653
- font-size: 11px;
654
- color: var(--text-dim);
655
- text-transform: uppercase;
656
- margin-bottom: 4px;
657
- }
658
- .birth-step.active .step-name { color: var(--lisa); }
659
- .birth-step.done .step-name { color: var(--you); }
660
- .birth-step .step-detail {
661
- color: var(--text);
662
- font-size: 19px;
663
- font-family: 'VT323', monospace;
664
- word-break: break-word;
665
- }
666
- .birth-step .step-cursor {
667
- display: inline-block;
668
- width: 8px;
669
- background: var(--lisa);
670
- height: 0.9em;
671
- vertical-align: middle;
672
- animation: blink 0.8s steps(2) infinite;
673
- }
674
- .birth-final {
675
- margin-top: 36px;
676
- text-align: center;
677
- color: var(--lisa);
678
- font-family: 'Press Start 2P', monospace;
679
- font-size: 16px;
680
- text-shadow: 0 0 10px var(--lisa);
681
- animation: starBlink 2s steps(3) infinite;
682
- opacity: 0;
683
- transition: opacity 0.6s ease-in;
684
- }
685
- .birth-final.shown { opacity: 1; }
686
- .birth-enter {
687
- margin: 24px auto 0;
688
- display: block;
689
- background: var(--panel-light);
690
- border: 4px solid var(--lisa);
691
- box-shadow: inset 2px 2px 0 #fff5, inset -2px -2px 0 #0008, 0 0 16px var(--lisa);
692
- color: var(--lisa);
693
- font-family: 'Press Start 2P', monospace;
694
- font-size: 14px;
695
- padding: 14px 32px;
696
- cursor: pointer;
697
- letter-spacing: 4px;
698
- opacity: 0;
699
- transition: opacity 0.6s ease-in;
700
- }
701
- .birth-enter.shown { opacity: 1; }
702
- .birth-enter:hover { background: var(--lisa); color: #000; }
703
- .birth-error {
704
- color: var(--error);
705
- text-align: center;
706
- margin-top: 24px;
707
- font-family: 'Press Start 2P', monospace;
708
- font-size: 11px;
709
- }
710
-
711
- /* ── API key config overlay (shown when no key is set yet) ─────────── */
712
- .cfg-overlay {
713
- position: fixed;
714
- inset: 0;
715
- background:
716
- radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%),
717
- url('/assets/background-tile.png');
718
- background-size: cover, 256px 256px;
719
- background-repeat: no-repeat, repeat;
720
- z-index: 9998;
721
- display: none;
722
- align-items: center;
723
- justify-content: center;
724
- padding: 32px;
725
- image-rendering: pixelated;
726
- }
727
- .cfg-overlay.open { display: flex; }
728
- .cfg-overlay::before {
729
- content: '';
730
- position: absolute;
731
- inset: 0;
732
- pointer-events: none;
733
- background: repeating-linear-gradient(
734
- to bottom,
735
- rgba(0,0,0,0) 0,
736
- rgba(0,0,0,0) 2px,
737
- rgba(0,0,0,0.25) 3px,
738
- rgba(0,0,0,0) 4px
739
- );
740
- z-index: 1;
741
- }
742
- .cfg-card {
743
- position: relative;
744
- z-index: 2;
745
- width: min(560px, 95vw);
746
- background: var(--panel);
747
- border: 4px solid var(--border);
748
- box-shadow: inset 2px 2px 0 var(--border-light), inset -2px -2px 0 #000, 0 0 0 2px #000, 0 0 24px rgba(108, 246, 225, 0.25);
749
- padding: 28px 32px;
750
- image-rendering: pixelated;
751
- }
752
- .cfg-stars {
753
- text-align: center;
754
- color: var(--lisa);
755
- font-family: 'Press Start 2P', monospace;
756
- font-size: 12px;
757
- letter-spacing: 6px;
758
- text-shadow: 0 0 8px var(--lisa);
759
- animation: starBlink 1.5s steps(3) infinite;
760
- }
761
- .cfg-title {
762
- text-align: center;
763
- color: var(--you);
764
- font-family: 'Press Start 2P', monospace;
765
- font-size: 16px;
766
- letter-spacing: 4px;
767
- margin: 16px 0 6px;
768
- text-shadow: 2px 2px 0 #000, 0 0 10px var(--you);
769
- }
770
- .cfg-sub {
771
- text-align: center;
772
- color: var(--text-dim);
773
- font-family: 'VT323', monospace;
774
- font-size: 18px;
775
- margin-bottom: 22px;
776
- line-height: 1.35;
777
- }
778
- .cfg-sub a { color: var(--lisa); text-decoration: underline; }
779
- .cfg-field {
780
- display: block;
781
- margin: 14px 0;
782
- }
783
- .cfg-label {
784
- display: block;
785
- font-family: 'Press Start 2P', monospace;
786
- font-size: 10px;
787
- color: var(--lisa);
788
- margin-bottom: 6px;
789
- letter-spacing: 1px;
790
- }
791
- .cfg-label .opt {
792
- color: var(--text-dim);
793
- font-size: 8px;
794
- margin-left: 6px;
795
- letter-spacing: 0;
796
- }
797
- .cfg-input {
798
- width: 100%;
799
- background: var(--panel);
800
- border: 3px solid var(--border);
801
- box-shadow:
802
- inset 2px 2px 0 #000,
803
- inset -2px -2px 0 var(--border-light),
804
- 0 0 0 2px #000;
805
- color: var(--text);
806
- font-family: 'VT323', monospace;
807
- font-size: 18px;
808
- padding: 10px 12px;
809
- image-rendering: pixelated;
810
- }
811
- .cfg-input:focus {
812
- outline: none;
813
- border-color: var(--you);
814
- }
815
- .cfg-help {
816
- color: var(--text-dim);
817
- font-family: 'VT323', monospace;
818
- font-size: 14px;
819
- margin-top: 4px;
820
- }
821
- .cfg-actions {
822
- margin-top: 22px;
823
- display: flex;
824
- justify-content: center;
825
- }
826
- .cfg-save {
827
- background: var(--panel-light);
828
- border: 4px solid var(--lisa);
829
- box-shadow: inset 2px 2px 0 #fff5, inset -2px -2px 0 #0008, 0 0 12px var(--lisa);
830
- color: var(--lisa);
831
- font-family: 'Press Start 2P', monospace;
832
- font-size: 12px;
833
- padding: 12px 28px;
834
- cursor: pointer;
835
- letter-spacing: 3px;
836
- image-rendering: pixelated;
837
- }
838
- .cfg-save:hover { background: var(--lisa); color: #000; }
839
- .cfg-save:active { transform: translate(2px, 2px); }
840
- .cfg-save:disabled { opacity: 0.55; cursor: wait; }
841
- .cfg-error {
842
- color: var(--error);
843
- font-family: 'Press Start 2P', monospace;
844
- font-size: 10px;
845
- text-align: center;
846
- margin-top: 14px;
847
- min-height: 14px;
848
- }
849
- .cfg-foot {
850
- margin-top: 18px;
851
- text-align: center;
852
- color: var(--text-dim);
853
- font-family: 'VT323', monospace;
854
- font-size: 14px;
855
- line-height: 1.4;
856
- }
857
- .cfg-foot code {
858
- color: var(--text);
859
- background: rgba(0, 0, 0, 0.3);
860
- padding: 1px 4px;
861
- }
862
- </style>
863
- </head><body>
864
- <div class="frame">
865
- <header class="panel">
866
- <div class="logo"><span class="star">★</span> LISA <span class="star">★</span></div>
867
- <div class="badges">
868
- <button class="badge" type="button" data-panel="soul"><img src="/assets/icon-soul.png" alt=""> SOUL</button>
869
- <button class="badge" type="button" data-panel="skills"><img src="/assets/icon-skill.png" alt=""> SKILLS</button>
870
- <button class="badge" type="button" data-panel="memory"><img src="/assets/icon-memory.png" alt=""> MEMORY</button>
871
- <button class="badge" type="button" data-panel="tools"><img src="/assets/icon-tool.png" alt=""> TOOLS</button>
872
- </div>
873
- </header>
874
-
875
- <div class="modal-bg" id="modalBg">
876
- <div class="modal">
877
- <div class="modal-head">
878
- <div class="modal-title" id="modalTitle">…</div>
879
- <button class="modal-close" id="modalClose">CLOSE [esc]</button>
880
- </div>
881
- <div class="modal-body" id="modalBody">…</div>
882
- </div>
883
- </div>
884
-
885
- <!-- API key config overlay (shown if no key is configured yet) -->
886
- <div class="cfg-overlay" id="cfgOverlay">
887
- <div class="cfg-card">
888
- <div class="cfg-stars">✦ ✦ ✦ ✦ ✦</div>
889
- <div class="cfg-title">SET · API · KEY</div>
890
- <div class="cfg-sub">
891
- Lisa needs an Anthropic API key to wake up.<br>
892
- <a href="https://console.anthropic.com/" target="_blank" rel="noopener">Get one at console.anthropic.com</a>
893
- </div>
894
- <form id="cfgForm">
895
- <label class="cfg-field">
896
- <span class="cfg-label">ANTHROPIC_API_KEY</span>
897
- <input class="cfg-input" id="cfgAnthropic" type="password" autocomplete="off"
898
- spellcheck="false" placeholder="sk-ant-..." required>
899
- </label>
900
- <label class="cfg-field">
901
- <span class="cfg-label">OPENAI_API_KEY <span class="opt">(optional · for gpt-* models)</span></span>
902
- <input class="cfg-input" id="cfgOpenai" type="password" autocomplete="off"
903
- spellcheck="false" placeholder="sk-...">
904
- </label>
905
- <div class="cfg-help">
906
- Saved to <code>~/.lisa/config.env</code> with mode 0600. Stays on this machine.
907
- </div>
908
- <div class="cfg-actions">
909
- <button class="cfg-save" id="cfgSave" type="submit">SAVE &amp; CONTINUE</button>
910
- </div>
911
- <div class="cfg-error" id="cfgError"></div>
912
- </form>
913
- </div>
914
- </div>
915
-
916
- <!-- Birth ritual full-screen overlay -->
917
- <div class="birth-overlay" id="birthOverlay">
918
- <div class="birth-content">
919
- <div class="birth-stars">✦ ✦ ✦ ✦ ✦</div>
920
- <div class="birth-title">B I R T H · R I T U A L</div>
921
- <div id="birthSteps"></div>
922
- <div class="birth-final" id="birthFinal"></div>
923
- <button class="birth-enter" id="birthEnter">ENTER</button>
924
- <div class="birth-error" id="birthError"></div>
925
- <div class="birth-stars" style="margin-top: 24px;">✦ ✦ ✦ ✦ ✦</div>
926
- </div>
927
- </div>
928
-
929
- <aside class="panel">
930
- <div class="mascot-frame">
931
- <img class="mascot" id="mascot" src="/assets/lisa-mascot.png" alt="Lisa">
932
- <div class="mascot-tag" id="mascotTag">neutral</div>
933
- </div>
934
- <div class="name">LISA</div>
935
- <div class="status"><span class="dot"></span>online</div>
936
- <div class="session-id" id="sessionId">…</div>
937
- </aside>
938
-
939
- <main class="panel">
940
- <div id="log"></div>
941
- </main>
942
-
943
- <div id="attachPreview"></div>
944
- <form id="form">
945
- <label id="attachBtn" title="Attach file">
946
- <input type="file" id="fileInput" accept="image/*,.pdf,.txt,.md,.csv,.json" multiple style="display:none">
947
- 📎
948
- </label>
949
- <textarea id="input" placeholder="Talk to Lisa… (Enter to send · Shift+Enter for newline)" autofocus></textarea>
950
- <button type="submit" id="sendBtn">
951
- <img src="/assets/icon-send.png" alt="">
952
- SEND
953
- </button>
954
- </form>
955
- </div>
956
-
957
- <script>
958
- const log = document.getElementById('log');
959
- const input = document.getElementById('input');
960
- const form = document.getElementById('form');
961
- const sendBtn = document.getElementById('sendBtn');
962
- const sessionEl = document.getElementById('sessionId');
963
- const fileInput = document.getElementById('fileInput');
964
- const attachPreview = document.getElementById('attachPreview');
965
-
966
- // ── Attached files state ──────────────────────────────────────────
967
- let pendingFiles = []; // Array of {name, mediaType, data (base64)}
968
-
969
- function readFileAsBase64(file) {
970
- return new Promise((resolve, reject) => {
971
- const reader = new FileReader();
972
- reader.onload = () => {
973
- const dataUrl = reader.result;
974
- const base64 = dataUrl.split(',')[1];
975
- resolve(base64);
976
- };
977
- reader.onerror = reject;
978
- reader.readAsDataURL(file);
979
- });
980
- }
981
-
982
- function inferMediaType(file) {
983
- if (file.type) return file.type;
984
- const ext = file.name.split('.').pop().toLowerCase();
985
- const map = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/plain', csv: 'text/csv', json: 'application/json' };
986
- return map[ext] || 'application/octet-stream';
987
- }
988
-
989
- function renderAttachPreview() {
990
- attachPreview.innerHTML = '';
991
- pendingFiles.forEach((f, i) => {
992
- const chip = document.createElement('span');
993
- chip.className = 'attach-chip';
994
- chip.textContent = f.name;
995
- const rm = document.createElement('button');
996
- rm.type = 'button';
997
- rm.className = 'attach-rm';
998
- rm.textContent = '×';
999
- rm.onclick = () => { pendingFiles.splice(i, 1); renderAttachPreview(); };
1000
- chip.appendChild(rm);
1001
- attachPreview.appendChild(chip);
1002
- });
1003
- }
1004
-
1005
- fileInput.addEventListener('change', async () => {
1006
- for (const file of fileInput.files) {
1007
- const data = await readFileAsBase64(file);
1008
- pendingFiles.push({ name: file.name, mediaType: inferMediaType(file), data });
1009
- }
1010
- fileInput.value = '';
1011
- renderAttachPreview();
1012
- });
1013
-
1014
- // Surface session id from server header on first request
1015
- fetch('/session').then(r => r.json()).then(s => sessionEl.textContent = s.id);
1016
-
1017
- // ── Persistent /events SSE: mood updates + idle messages, lifetime of page
1018
- function connectEvents() {
1019
- const es = new EventSource('/events');
1020
- let idlePulseEl = null;
1021
- es.addEventListener('message', (e) => {
1022
- const ev = JSON.parse(e.data);
1023
- if (ev.type === 'mood') {
1024
- setMood(ev.slug);
1025
- } else if (ev.type === 'idle_start') {
1026
- if (!idlePulseEl) {
1027
- idlePulseEl = document.createElement('div');
1028
- idlePulseEl.className = 'idle-pulse';
1029
- idlePulseEl.textContent = '⋯ Lisa is thinking on her own time ⋯';
1030
- log.appendChild(idlePulseEl);
1031
- log.scrollTop = log.scrollHeight;
1032
- }
1033
- } else if (ev.type === 'idle_message') {
1034
- if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
1035
- const block = document.createElement('div');
1036
- block.className = 'idle-block';
1037
- const head = document.createElement('div');
1038
- head.className = 'idle-head';
1039
- head.textContent = '★ WHILE YOU WERE AWAY';
1040
- const time = document.createElement('span');
1041
- time.className = 'idle-time';
1042
- try { time.textContent = new Date(ev.at).toLocaleTimeString(); } catch {}
1043
- head.appendChild(time);
1044
- block.appendChild(head);
1045
- const body = document.createElement('div');
1046
- body.textContent = ev.text;
1047
- block.appendChild(body);
1048
- log.appendChild(block);
1049
- log.scrollTop = log.scrollHeight;
1050
- } else if (ev.type === 'idle_done') {
1051
- if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
1052
- } else if (ev.type === 'idle_error') {
1053
- if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
1054
- const e2 = document.createElement('div');
1055
- e2.className = 'err';
1056
- e2.textContent = '[idle error] ' + ev.message;
1057
- log.appendChild(e2);
1058
- }
1059
- });
1060
- es.onerror = () => {
1061
- es.close();
1062
- setTimeout(connectEvents, 3000); // reconnect
1063
- };
1064
- }
1065
- connectEvents();
1066
-
1067
- // ── API key config gate: show overlay if no key is configured ─────
1068
- const cfgOverlay = document.getElementById('cfgOverlay');
1069
- const cfgForm = document.getElementById('cfgForm');
1070
- const cfgAnthropic = document.getElementById('cfgAnthropic');
1071
- const cfgOpenai = document.getElementById('cfgOpenai');
1072
- const cfgSaveBtn = document.getElementById('cfgSave');
1073
- const cfgError = document.getElementById('cfgError');
1074
-
1075
- cfgForm.addEventListener('submit', async (ev) => {
1076
- ev.preventDefault();
1077
- cfgError.textContent = '';
1078
- const anthropic = cfgAnthropic.value.trim();
1079
- const openai = cfgOpenai.value.trim();
1080
- if (!anthropic) {
1081
- cfgError.textContent = 'ANTHROPIC_API_KEY is required.';
1082
- return;
1083
- }
1084
- cfgSaveBtn.disabled = true;
1085
- try {
1086
- const res = await fetch('/api/config/save', {
1087
- method: 'POST',
1088
- headers: {'content-type': 'application/json'},
1089
- body: JSON.stringify({ anthropicKey: anthropic, openaiKey: openai || undefined }),
1090
- });
1091
- if (!res.ok) {
1092
- const txt = await res.text().catch(() => '');
1093
- cfgError.textContent = 'Save failed: HTTP ' + res.status + (txt ? ' — ' + txt.slice(0, 120) : '');
1094
- cfgSaveBtn.disabled = false;
1095
- return;
1096
- }
1097
- cfgAnthropic.value = '';
1098
- cfgOpenai.value = '';
1099
- cfgOverlay.classList.remove('open');
1100
- maybeBirth();
1101
- } catch (err) {
1102
- cfgError.textContent = 'Save failed: ' + err.message;
1103
- cfgSaveBtn.disabled = false;
1104
- }
1105
- });
1106
-
1107
- // ── Birth ritual: show overlay if Lisa hasn't been born yet ───────
1108
- const birthOverlay = document.getElementById('birthOverlay');
1109
- const birthStepsEl = document.getElementById('birthSteps');
1110
- const birthFinalEl = document.getElementById('birthFinal');
1111
- const birthEnterBtn = document.getElementById('birthEnter');
1112
- const birthErrorEl = document.getElementById('birthError');
1113
-
1114
- birthEnterBtn.addEventListener('click', () => {
1115
- birthOverlay.classList.remove('open');
1116
- setTimeout(() => location.reload(), 300);
1117
- });
1118
-
1119
- async function maybeBirth() {
1120
- const status = await fetch('/api/soul').then(r => r.json());
1121
- if (status.born) return;
1122
- birthOverlay.classList.add('open');
1123
- startBirthStream();
1124
- }
1125
-
1126
- function appendBirthStep(step) {
1127
- // close previous active
1128
- const prevActive = birthStepsEl.querySelector('.birth-step.active');
1129
- if (prevActive) {
1130
- prevActive.classList.remove('active');
1131
- prevActive.classList.add('done');
1132
- }
1133
- const div = document.createElement('div');
1134
- div.className = 'birth-step active';
1135
- const name = document.createElement('div');
1136
- name.className = 'step-name';
1137
- name.textContent = step;
1138
- div.appendChild(name);
1139
- const detail = document.createElement('div');
1140
- detail.className = 'step-detail';
1141
- const cursor = document.createElement('span');
1142
- cursor.className = 'step-cursor';
1143
- detail.appendChild(cursor);
1144
- div.appendChild(detail);
1145
- birthStepsEl.appendChild(div);
1146
- setTimeout(() => div.classList.add('shown'), 50);
1147
- return detail;
1148
- }
1149
-
1150
- function typewriter(el, text, done) {
1151
- // remove cursor temporarily
1152
- const cursor = el.querySelector('.step-cursor');
1153
- if (cursor) cursor.remove();
1154
- let i = 0;
1155
- const speed = Math.max(8, Math.min(28, 600 / text.length));
1156
- function tick() {
1157
- if (i >= text.length) {
1158
- // re-add cursor
1159
- const c = document.createElement('span');
1160
- c.className = 'step-cursor';
1161
- el.appendChild(c);
1162
- done && done();
1163
- return;
1164
- }
1165
- el.appendChild(document.createTextNode(text[i]));
1166
- i++;
1167
- el.parentElement.parentElement.scrollTop = el.parentElement.parentElement.scrollHeight;
1168
- setTimeout(tick, speed);
1169
- }
1170
- tick();
1171
- }
1172
-
1173
- async function startBirthStream() {
1174
- birthErrorEl.textContent = '';
1175
- let currentDetail = null;
1176
- let queue = [];
1177
- let processing = false;
1178
-
1179
- function processQueue() {
1180
- if (processing) return;
1181
- if (queue.length === 0) return;
1182
- processing = true;
1183
- const ev = queue.shift();
1184
- if (ev.kind === 'step') {
1185
- currentDetail = appendBirthStep(ev.name);
1186
- typewriter(currentDetail, ev.detail || '', () => {
1187
- processing = false;
1188
- processQueue();
1189
- });
1190
- } else if (ev.kind === 'done') {
1191
- const last = birthStepsEl.querySelector('.birth-step.active');
1192
- if (last) { last.classList.remove('active'); last.classList.add('done'); }
1193
- birthFinalEl.textContent = ev.message;
1194
- birthFinalEl.classList.add('shown');
1195
- birthEnterBtn.classList.add('shown');
1196
- processing = false;
1197
- } else if (ev.kind === 'error') {
1198
- birthErrorEl.textContent = ev.message;
1199
- processing = false;
1200
- }
1201
- }
1202
-
1203
- try {
1204
- const res = await fetch('/api/birth', { method: 'POST' });
1205
- if (!res.ok) {
1206
- birthErrorEl.textContent = 'Birth failed: HTTP ' + res.status + '. Check ANTHROPIC_API_KEY.';
1207
- return;
1208
- }
1209
- const reader = res.body.getReader();
1210
- const decoder = new TextDecoder();
1211
- let buf = '';
1212
- while (true) {
1213
- const { value, done } = await reader.read();
1214
- if (done) break;
1215
- buf += decoder.decode(value, { stream: true });
1216
- let idx;
1217
- while ((idx = buf.indexOf('\\n\\n')) >= 0) {
1218
- const event = buf.slice(0, idx);
1219
- buf = buf.slice(idx + 2);
1220
- const m = event.match(/^data: (.*)$/m);
1221
- if (!m) continue;
1222
- const ev = JSON.parse(m[1]);
1223
- queue.push(ev);
1224
- processQueue();
1225
- }
1226
- }
1227
- } catch (err) {
1228
- birthErrorEl.textContent = 'Birth failed: ' + err.message;
1229
- }
1230
- }
1231
-
1232
- async function startupGate() {
1233
- let cfg;
1234
- try {
1235
- cfg = await fetch('/api/config/status').then(r => r.json());
1236
- } catch {
1237
- // server unreachable — let the user retry by reloading; no overlay
1238
- return;
1239
- }
1240
- if (!cfg.configured) {
1241
- cfgOverlay.classList.add('open');
1242
- setTimeout(() => cfgAnthropic.focus(), 50);
1243
- return;
1244
- }
1245
- maybeBirth();
1246
- }
1247
- startupGate();
1248
-
1249
- // ── history load & infinite-scroll ──────────────────────────────────
1250
- let historyPage = 0;
1251
- let historyLoading = false;
1252
- let historyExhausted = false;
1253
-
1254
- function textOfMessage(msg) {
1255
- if (typeof msg.content === 'string') return msg.content.trim();
1256
- if (!Array.isArray(msg.content)) return '';
1257
- // Only show text blocks; skip tool_use / tool_result blocks
1258
- return msg.content
1259
- .filter(b => b.type === 'text')
1260
- .map(b => b.text)
1261
- .join('')
1262
- .trim();
1263
- }
1264
-
1265
- function prependHistoryMessages(messages) {
1266
- const fragment = document.createDocumentFragment();
1267
- for (const msg of messages) {
1268
- const text = textOfMessage(msg);
1269
- if (!text) continue; // skip tool-only turns
1270
- const roleDiv = document.createElement('div');
1271
- roleDiv.className = 'role ' + (msg.role === 'user' ? 'you' : 'lisa');
1272
- roleDiv.textContent = msg.role === 'user' ? 'YOU' : 'LISA';
1273
- const span = document.createElement('span');
1274
- span.className = 'msg';
1275
- span.textContent = text;
1276
- fragment.appendChild(roleDiv);
1277
- fragment.appendChild(span);
1278
- }
1279
- log.insertBefore(fragment, log.firstChild);
1280
- }
1281
-
1282
- async function loadHistoryPage() {
1283
- if (historyLoading || historyExhausted) return;
1284
- historyLoading = true;
1285
- const prevScrollHeight = log.scrollHeight;
1286
- try {
1287
- const data = await fetch('/api/history?page=' + historyPage).then(r => r.json());
1288
- if (data.messages.length > 0) {
1289
- prependHistoryMessages(data.messages);
1290
- // Keep scroll position stable after prepend
1291
- log.scrollTop = log.scrollHeight - prevScrollHeight;
1292
- historyPage++;
1293
- }
1294
- if (!data.hasMore) {
1295
- historyExhausted = true;
1296
- if (historyPage > 1) {
1297
- const marker = document.createElement('div');
1298
- marker.style.cssText = 'text-align:center;color:var(--text-dim);font-size:14px;padding:8px 0;';
1299
- marker.textContent = '— 历史记录已全部加载 —';
1300
- log.insertBefore(marker, log.firstChild);
1301
- }
1302
- }
1303
- } finally {
1304
- historyLoading = false;
1305
- }
1306
- }
1307
-
1308
- // Load first page immediately on start
1309
- loadHistoryPage();
1310
-
1311
- // Scroll-to-top → load more
1312
- log.addEventListener('scroll', () => {
1313
- if (log.scrollTop < 80) loadHistoryPage();
1314
- });
1315
-
1316
- // ── mascot crossfade on mood event ──────────────────────────────────
1317
- const mascotEl = document.getElementById('mascot');
1318
- const mascotTagEl = document.getElementById('mascotTag');
1319
- let currentMood = 'neutral';
1320
- function setMood(slug) {
1321
- if (!slug || slug === currentMood) return;
1322
- const url = '/assets/lisa/' + encodeURIComponent(slug) + '.png';
1323
- // Probe the asset first so we don't fade to a 404.
1324
- const probe = new Image();
1325
- probe.onload = () => {
1326
- mascotEl.classList.add('fading');
1327
- setTimeout(() => {
1328
- mascotEl.src = url;
1329
- mascotTagEl.textContent = slug;
1330
- mascotEl.classList.remove('fading');
1331
- currentMood = slug;
1332
- }, 250);
1333
- };
1334
- probe.onerror = () => { /* asset not generated yet — keep current */ };
1335
- probe.src = url;
1336
- }
1337
-
1338
- // ── modal panel: SKILLS / MEMORY / TOOLS ──────────────────────────────
1339
- const modalBg = document.getElementById('modalBg');
1340
- const modalTitle = document.getElementById('modalTitle');
1341
- const modalBody = document.getElementById('modalBody');
1342
- const modalClose = document.getElementById('modalClose');
1343
-
1344
- function escapeHtml(s) {
1345
- return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
1346
- }
1347
- function openModal(title, html) {
1348
- modalTitle.textContent = title;
1349
- modalBody.innerHTML = html;
1350
- modalBg.classList.add('open');
1351
- }
1352
- function closeModal() { modalBg.classList.remove('open'); }
1353
- modalClose.addEventListener('click', closeModal);
1354
- modalBg.addEventListener('click', (e) => { if (e.target === modalBg) closeModal(); });
1355
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
1356
-
1357
- async function showSkills() {
1358
- openModal('SKILLS', '<div class="empty">loading…</div>');
1359
- const data = await fetch('/api/skills').then(r => r.json());
1360
- if (!data.skills.length) {
1361
- modalBody.innerHTML = '<div class="empty">No skills saved yet. Lisa will start saving useful workflows as you use her.</div>';
1362
- return;
1363
- }
1364
- modalBody.innerHTML = data.skills.map(s =>
1365
- '<div class="item"><div class="name">' + escapeHtml(s.name) + '</div><div class="desc">' + escapeHtml(s.description) + '</div></div>'
1366
- ).join('');
1367
- }
1368
-
1369
- async function showMemory() {
1370
- openModal('MEMORY', '<div class="empty">loading…</div>');
1371
- const data = await fetch('/api/memory').then(r => r.json());
1372
- modalBody.innerHTML =
1373
- '<h3>USER.md — what Lisa remembers about you</h3>' +
1374
- '<pre>' + escapeHtml(data.user || '(empty)') + '</pre>' +
1375
- '<h3>MEMORY.md — Lisa\\'s working notes</h3>' +
1376
- '<pre>' + escapeHtml(data.memory || '(empty)') + '</pre>';
1377
- }
1378
-
1379
- async function showTools() {
1380
- openModal('TOOLS', '<div class="empty">loading…</div>');
1381
- const data = await fetch('/api/tools').then(r => r.json());
1382
- modalBody.innerHTML = data.tools.map(t =>
1383
- '<div class="item"><div class="name">' + escapeHtml(t.name) + '</div><div class="desc">' + escapeHtml(t.description) + '</div></div>'
1384
- ).join('');
1385
- }
1386
-
1387
- async function showSoul() {
1388
- openModal('★ SOUL', '<div class="empty">loading…</div>');
1389
- const data = await fetch('/api/soul').then(r => r.json());
1390
- if (!data.born) {
1391
- modalBody.innerHTML = '<div class="empty">Lisa hasn\\'t been born yet. Restart the CLI without --no-birth and the birth ritual will run.</div>';
1392
- return;
1393
- }
1394
- const s = data.summary;
1395
- let html = '';
1396
- html += '<h3>name</h3><div>' + escapeHtml(s.name) + '</div>';
1397
- html += '<h3>born</h3><div>' + escapeHtml(s.seed.bornAt) + ' · 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>';
1398
- html += '<h3>identity</h3><pre>' + escapeHtml(s.identity) + '</pre>';
1399
- html += '<h3>purpose</h3><pre>' + escapeHtml(s.purpose) + '</pre>';
1400
- html += '<h3>constitution</h3><pre>' + escapeHtml(s.constitution) + '</pre>';
1401
- if (s.values?.length) {
1402
- html += '<h3>values</h3>' + s.values.map(v =>
1403
- '<div class="item"><div class="name">' + escapeHtml(v.title) + '</div><div class="desc">' + escapeHtml(v.body) + '</div></div>'
1404
- ).join('');
1405
- }
1406
- if (s.opinions?.length) {
1407
- html += '<h3>opinions</h3>' + s.opinions.map(o =>
1408
- '<div class="item"><div class="name">' + escapeHtml(o.stance) + ' (conf ' + o.confidence.toFixed(2) + ')</div></div>'
1409
- ).join('');
1410
- }
1411
- if (s.desires?.length) {
1412
- html += '<h3>desires</h3>' + s.desires.map(d =>
1413
- '<div class="item"><div class="name">' + escapeHtml(d.what) + (d.actionable ? ' [heartbeat-active]' : '') + '</div><div class="desc">' + escapeHtml(d.why) + '</div></div>'
1414
- ).join('');
1415
- }
1416
- html += '<h3>emotions</h3>' + Object.entries(s.emotions.values).map(([k, v]) => {
1417
- const len = 12;
1418
- const filled = Math.round(Math.abs(v) * len);
1419
- const bar = '█'.repeat(filled) + '░'.repeat(len - filled);
1420
- return '<div class="item"><div class="name">' + escapeHtml(k) + '</div><div class="desc">' + (v < 0 ? '-' : ' ') + bar + ' ' + v.toFixed(2) + '</div></div>';
1421
- }).join('');
1422
- if (s.tampered?.length) {
1423
- html += '<h3>⚠ tampered</h3><div>External edits detected on: ' + s.tampered.map(escapeHtml).join(', ') + '</div>';
1424
- }
1425
- html += '<h3 style="color: var(--text-dim); font-size: 9px;">privacy note</h3><div class="empty">Her journal lives at ~/.lisa/soul/journal/ but is intentionally not shown here — that is hers to keep.</div>';
1426
- modalBody.innerHTML = html;
1427
- }
1428
-
1429
- document.querySelectorAll('.badge').forEach(b => {
1430
- b.addEventListener('click', () => {
1431
- const which = b.dataset.panel;
1432
- if (which === 'soul') showSoul();
1433
- else if (which === 'skills') showSkills();
1434
- else if (which === 'memory') showMemory();
1435
- else if (which === 'tools') showTools();
1436
- });
1437
- });
1438
-
1439
- let currentLisaSpan = null;
1440
- let pendingTools = new Map(); // tool_use_id-ish key -> tool block element
1441
- let thinkingEl = null;
1442
-
1443
- function el(tag, cls, text) {
1444
- const node = document.createElement(tag);
1445
- if (cls) node.className = cls;
1446
- if (text != null) node.textContent = text;
1447
- log.appendChild(node);
1448
- log.scrollTop = log.scrollHeight;
1449
- return node;
1450
- }
1451
-
1452
- function ensureLisaSpan() {
1453
- if (currentLisaSpan) return currentLisaSpan;
1454
- if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
1455
- el('div', 'role lisa', 'LISA');
1456
- currentLisaSpan = el('span', 'msg', '');
1457
- return currentLisaSpan;
1458
- }
1459
-
1460
- // Pick the most useful one-line preview from a tool input object.
1461
- function previewInput(name, input) {
1462
- if (!input || typeof input !== 'object') return '';
1463
- const order = ['command', 'pattern', 'query', 'path', 'description', 'audio_path', 'text', 'name', 'action', 'entry'];
1464
- for (const k of order) {
1465
- if (typeof input[k] === 'string' && input[k]) {
1466
- let v = input[k].replace(/\\s+/g, ' ').trim();
1467
- if (v.length > 120) v = v.slice(0, 117) + '...';
1468
- return v;
1469
- }
1470
- }
1471
- // Fallback: stringify, trimmed
1472
- try {
1473
- const s = JSON.stringify(input);
1474
- return s.length > 120 ? s.slice(0, 117) + '...' : s;
1475
- } catch { return ''; }
1476
- }
1477
-
1478
- async function send(message) {
1479
- input.value = '';
1480
- input.style.height = 'auto';
1481
- sendBtn.disabled = true;
1482
- el('div', 'role you', 'YOU');
1483
- el('span', 'msg', message || '(attachment)');
1484
- if (pendingFiles.length) {
1485
- const names = pendingFiles.map(f => f.name).join(', ');
1486
- el('span', 'msg attach-label', '📎 ' + names);
1487
- }
1488
- const filesToSend = [...pendingFiles];
1489
- pendingFiles = [];
1490
- renderAttachPreview();
1491
- // Reset state for this turn
1492
- currentLisaSpan = null;
1493
- pendingTools.clear();
1494
- thinkingEl = el('div', 'thinking', '⋯ thinking');
1495
- try {
1496
- const res = await fetch('/chat', {
1497
- method: 'POST',
1498
- headers: {'content-type': 'application/json'},
1499
- body: JSON.stringify({message, files: filesToSend}),
1500
- });
1501
- const reader = res.body.getReader();
1502
- const decoder = new TextDecoder();
1503
- let buf = '';
1504
- while (true) {
1505
- const {value, done} = await reader.read();
1506
- if (done) break;
1507
- buf += decoder.decode(value, {stream: true});
1508
- let idx;
1509
- while ((idx = buf.indexOf('\\n\\n')) >= 0) {
1510
- const evRaw = buf.slice(0, idx);
1511
- buf = buf.slice(idx + 2);
1512
- const m = evRaw.match(/^data: (.*)$/m);
1513
- if (!m) continue;
1514
- const ev = JSON.parse(m[1]);
1515
- if (ev.type === 'text') {
1516
- ensureLisaSpan().textContent += ev.text;
1517
- } else if (ev.type === 'tool_start') {
1518
- if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
1519
- // Close out any current LISA span — next text starts a new one.
1520
- currentLisaSpan = null;
1521
- const block = el('div', 'tool-block', null);
1522
- const head = document.createElement('div');
1523
- head.className = 'tool-head';
1524
- head.innerHTML = '<span class="tool-icon">⚙</span> <span class="tool-name"></span> <span class="tool-spinner">...</span>';
1525
- head.querySelector('.tool-name').textContent = ev.name;
1526
- block.appendChild(head);
1527
- const preview = previewInput(ev.name, ev.input);
1528
- if (preview) {
1529
- const p = document.createElement('div');
1530
- p.className = 'tool-input';
1531
- p.textContent = preview;
1532
- block.appendChild(p);
1533
- }
1534
- pendingTools.set(ev.name, block);
1535
- } else if (ev.type === 'tool_end') {
1536
- const block = pendingTools.get(ev.name);
1537
- if (block) {
1538
- const spinner = block.querySelector('.tool-spinner');
1539
- if (spinner) spinner.textContent = ev.isError ? '✗' : '✓';
1540
- if (ev.isError) block.classList.add('tool-error');
1541
- if (ev.resultPreview) {
1542
- const r = document.createElement('div');
1543
- r.className = 'tool-result';
1544
- r.textContent = ev.resultPreview;
1545
- block.appendChild(r);
1546
- }
1547
- pendingTools.delete(ev.name);
1548
- }
1549
- } else if (ev.type === 'mood') {
1550
- setMood(ev.slug);
1551
- } else if (ev.type === 'error') {
1552
- el('div', 'err', '[error] ' + ev.message);
1553
- } else if (ev.type === 'done') {
1554
- if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
1555
- }
1556
- }
1557
- }
1558
- if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
1559
- } catch (err) {
1560
- if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
1561
- el('div', 'err', '[error] ' + err.message);
1562
- } finally {
1563
- sendBtn.disabled = false;
1564
- input.focus();
1565
- }
1566
- }
1567
-
1568
- form.addEventListener('submit', (ev) => {
1569
- ev.preventDefault();
1570
- const msg = input.value.trim();
1571
- if (msg || pendingFiles.length) send(msg);
1572
- });
1573
-
1574
- input.addEventListener('keydown', (ev) => {
1575
- if (ev.key === 'Enter' && !ev.shiftKey) {
1576
- ev.preventDefault();
1577
- form.dispatchEvent(new Event('submit'));
1578
- }
1579
- });
1580
- input.addEventListener('input', () => {
1581
- input.style.height = 'auto';
1582
- input.style.height = Math.min(input.scrollHeight, 200) + 'px';
1583
- });
1584
-
1585
- // ─── PWA: register service worker + iOS install hint ─────────────────
1586
- if ('serviceWorker' in navigator) {
1587
- navigator.serviceWorker.register('/sw.js').catch(err => {
1588
- console.warn('[pwa] sw register failed:', err);
1589
- });
1590
- }
1591
- // iOS Safari doesn't fire beforeinstallprompt. Show a one-time hint to
1592
- // the user instead. Suppress if already running standalone (after add).
1593
- (function() {
1594
- const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
1595
- const isStandalone = window.matchMedia('(display-mode: standalone)').matches
1596
- || /** @type {any} */ (window.navigator).standalone === true;
1597
- if (!isiOS || isStandalone) return;
1598
- if (localStorage.getItem('lisa.pwa.dismissed') === '1') return;
1599
- // Defer 5s so the chat UI shows first.
1600
- setTimeout(() => {
1601
- const banner = document.createElement('div');
1602
- banner.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;background:var(--panel);border:2px solid var(--border-light);padding:8px 10px;font-family:VT323,monospace;color:var(--text);font-size:14px;z-index:9999;display:flex;gap:8px;align-items:center;';
1603
- banner.innerHTML = '✦ Add Lisa to Home Screen: Share button → "Add to Home Screen"';
1604
- const dismiss = document.createElement('button');
1605
- dismiss.textContent = '✕';
1606
- dismiss.style.cssText = 'background:transparent;border:none;color:var(--text);cursor:pointer;font-size:16px;margin-left:auto;';
1607
- dismiss.onclick = () => {
1608
- localStorage.setItem('lisa.pwa.dismissed', '1');
1609
- banner.remove();
1610
- };
1611
- banner.appendChild(dismiss);
1612
- document.body.appendChild(banner);
1613
- }, 5000);
1614
- })();
1615
- </script>
1616
- </body></html>`;
1617
22
  async function resumeOrCreateWebSession(model) {
1618
23
  const lastId = await readActiveWebSession();
1619
24
  if (lastId) {
@@ -1697,6 +102,30 @@ export async function startWebServer(opts) {
1697
102
  }
1698
103
  };
1699
104
  moodBus.on("mood", (slug) => broadcast({ type: "mood", slug }));
105
+ // Surface "thinking" to long-lived viewers (web GUI, island widget). Each
106
+ // event is one tick — surfaces toggle their own indicator. Multiple
107
+ // concurrent turns (e.g. heartbeat + user) overlap; first chatStart wins
108
+ // the visual, last chatEnd clears it. Best-effort.
109
+ moodBus.on("chat_start", () => broadcast({ type: "chat_start" }));
110
+ moodBus.on("chat_end", () => broadcast({ type: "chat_end" }));
111
+ // ── Claude Code session monitoring (issue #27) ──────────────────────
112
+ // Watches ~/.claude/projects/ for jsonl activity and forwards updates
113
+ // to the same /events SSE. Privacy: only mtime + size + filename, never
114
+ // message contents (see src/integrations/claude-code/watcher.ts).
115
+ const claudeWatcher = new ClaudeCodeWatcher({
116
+ log: (msg) => console.error(msg),
117
+ });
118
+ claudeWatcher.on("update", (payload) => {
119
+ broadcast({ type: "claude_session_update", ...payload });
120
+ });
121
+ void claudeWatcher.start();
122
+ // ── Island unread tracking (Phase 1 of MAC_ISLAND_PLAN) ─────────────
123
+ // The island widget caches "last idle_message" so a fresh tab opening
124
+ // mid-conversation knows there's something to read. Cleared via
125
+ // POST /api/island/dismiss-unread. Per design doc §6 Q2: latest wins,
126
+ // no inbox-style accumulation.
127
+ let lastIdleMessage = null;
128
+ let serverStartedAt = Date.now();
1700
129
  // ── Idle mode ───────────────────────────────────────────────────────
1701
130
  let idleRunning = false;
1702
131
  if (opts.idleMinutes && opts.idleMinutes > 0) {
@@ -1730,6 +159,7 @@ export async function startWebServer(opts) {
1730
159
  role: "assistant",
1731
160
  content: [{ type: "text", text: `[while you were away]\n${result.text}` }],
1732
161
  });
162
+ lastIdleMessage = { text: result.text, at: startedAt };
1733
163
  broadcast({ type: "idle_message", text: result.text, at: startedAt });
1734
164
  }
1735
165
  }
@@ -1749,7 +179,62 @@ export async function startWebServer(opts) {
1749
179
  const url = req.url ?? "/";
1750
180
  if (req.method === "GET" && url === "/") {
1751
181
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1752
- res.end(HTML);
182
+ res.end(MAIN_HTML);
183
+ return;
184
+ }
185
+ // Island widget — designed to be opened in a tiny browser window
186
+ // (Arc, Vivaldi PWA, Safari split). See docs/MAC_ISLAND_PLAN.md.
187
+ if (req.method === "GET" && url === "/island") {
188
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
189
+ res.end(ISLAND_HTML);
190
+ return;
191
+ }
192
+ // Light status endpoint for the island. Polled every 5–30s as a
193
+ // fallback when SSE has been quiet.
194
+ if (req.method === "GET" && url === "/api/island/ping") {
195
+ let currentDesire = null;
196
+ try {
197
+ const desires = await listDesires();
198
+ const actionable = desires.find((d) => d.actionable);
199
+ currentDesire = (actionable ?? desires[0])?.what ?? null;
200
+ }
201
+ catch {
202
+ // listDesires can fail before soul is born; that's fine.
203
+ }
204
+ res.writeHead(200, { "content-type": "application/json" });
205
+ res.end(JSON.stringify({
206
+ online: true,
207
+ mood: moodBus.current(),
208
+ has_unread_idle_message: lastIdleMessage !== null,
209
+ last_idle_message_at: lastIdleMessage?.at ?? null,
210
+ last_idle_message_text: lastIdleMessage?.text ?? null,
211
+ current_desire: currentDesire,
212
+ uptime_sec: Math.round((Date.now() - serverStartedAt) / 1000),
213
+ }));
214
+ return;
215
+ }
216
+ if (req.method === "POST" && url === "/api/island/dismiss-unread") {
217
+ lastIdleMessage = null;
218
+ res.writeHead(200, { "content-type": "application/json" });
219
+ res.end(JSON.stringify({ ok: true }));
220
+ return;
221
+ }
222
+ // Claude Code session monitoring (issue #27). Returns sessions
223
+ // with activity in the last 30 minutes plus their derived state
224
+ // (Phase 2: working / waiting / error / unknown).
225
+ if (req.method === "GET" && url === "/api/claude/sessions") {
226
+ const sessions = claudeWatcher.listActive().map((s) => ({
227
+ project: s.projectLabel,
228
+ projectEncoded: s.projectEncoded,
229
+ sessionId: s.sessionId,
230
+ lastMtime: new Date(s.lastMtime).toISOString(),
231
+ size: s.size,
232
+ state: s.state,
233
+ stateReason: s.stateReason,
234
+ cwd: s.cwd,
235
+ }));
236
+ res.writeHead(200, { "content-type": "application/json" });
237
+ res.end(JSON.stringify({ sessions }));
1753
238
  return;
1754
239
  }
1755
240
  // PWA manifest. Lets users add Lisa to the home screen on iOS / Android
@@ -1786,7 +271,7 @@ export async function startWebServer(opts) {
1786
271
  "service-worker-allowed": "/",
1787
272
  });
1788
273
  res.end(`
1789
- const CACHE = 'lisa-v1';
274
+ const CACHE = 'lisa-v2-redesign';
1790
275
  const ASSET_PATHS = ['/assets/lisa-mascot.png', '/assets/background-tile.png',
1791
276
  '/assets/icon-soul.png', '/assets/icon-skill.png', '/assets/icon-memory.png',
1792
277
  '/assets/icon-tool.png', '/assets/icon-send.png'];