ltcai 4.6.1 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,22 @@
1
1
  import * as React from "react";
2
- import { useQuery, useQueryClient } from "@tanstack/react-query";
3
- import { ImagePlus, Search, Send } from "lucide-react";
4
- import { latticeApi } from "@/api/client";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ Activity,
5
+ Archive,
6
+ ArrowLeft,
7
+ ChevronDown,
8
+ DatabaseBackup,
9
+ Download,
10
+ Eye,
11
+ ImagePlus,
12
+ RotateCcw,
13
+ Search,
14
+ Send,
15
+ ServerCog,
16
+ ShieldCheck,
17
+ Users,
18
+ } from "lucide-react";
19
+ import { latticeApi, type ApiResult } from "@/api/client";
5
20
  import { Button } from "@/components/ui/button";
6
21
  import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
7
22
  import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
@@ -51,9 +66,16 @@ const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
51
66
  { level: 5, label: "Knowledge Graph", state: "synthesizing" },
52
67
  ];
53
68
 
69
+ const STARTER_PROMPTS = [
70
+ "Remember this decision: ",
71
+ "What do I already know about ",
72
+ "Help me turn this project context into a plan: ",
73
+ ];
74
+
54
75
  export default function App() {
55
76
  const theme = useAppStore((state) => state.theme);
56
77
  const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
78
+ const route = useHashRoute();
57
79
  const { state: brainState, intensity, setBrain } = useBrainState();
58
80
 
59
81
  React.useEffect(() => {
@@ -78,11 +100,35 @@ export default function App() {
78
100
  return (
79
101
  <div className="brain-space">
80
102
  <div className="brain-field" />
81
- <BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
103
+ {route.startsWith("/admin") ? (
104
+ <AdminConsole onBack={() => navigateHash("/brain")} />
105
+ ) : (
106
+ <BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
107
+ )}
82
108
  </div>
83
109
  );
84
110
  }
85
111
 
112
+ function useHashRoute() {
113
+ const read = React.useCallback(() => {
114
+ const hash = window.location.hash.replace(/^#/, "");
115
+ return hash.startsWith("/") ? hash : "/brain";
116
+ }, []);
117
+ const [route, setRoute] = React.useState(read);
118
+
119
+ React.useEffect(() => {
120
+ const onHashChange = () => setRoute(read());
121
+ window.addEventListener("hashchange", onHashChange);
122
+ return () => window.removeEventListener("hashchange", onHashChange);
123
+ }, [read]);
124
+
125
+ return route;
126
+ }
127
+
128
+ function navigateHash(route: string) {
129
+ window.location.hash = route;
130
+ }
131
+
86
132
  function useBrainState() {
87
133
  const [state, setState] = React.useState<BrainState>("idle");
88
134
  const [intensity, setIntensity] = React.useState(0.58);
@@ -274,14 +320,33 @@ function BrainHome({
274
320
  <h1>Lattice Brain</h1>
275
321
  <span>{currentDepth.label}</span>
276
322
  </div>
323
+ <div className="brain-ownership-strip" aria-label="Brain ownership guarantees">
324
+ <span>Local-first</span>
325
+ <span>Portable</span>
326
+ <span>Private</span>
327
+ </div>
277
328
  <div>{modelName}</div>
329
+ <button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
330
+ <ShieldCheck className="h-3.5 w-3.5" />
331
+ Admin
332
+ </button>
278
333
  </div>
279
334
 
280
335
  <div ref={streamRef} className="brain-stream">
281
336
  {messages.length === 0 ? (
282
337
  <div className="mind-empty">
283
- <div className="mind-empty-kicker">Begin</div>
284
- <div>What are you thinking about?</div>
338
+ <div className="mind-empty-kicker">Your durable context</div>
339
+ <div className="mind-empty-title">Start with what should not be forgotten.</div>
340
+ <p>
341
+ Lattice keeps your documents, conversations, projects, and decisions available while models can change around them.
342
+ </p>
343
+ <div className="mind-empty-prompts" aria-label="Starter prompts">
344
+ {STARTER_PROMPTS.map((prompt) => (
345
+ <button key={prompt} type="button" onClick={() => setDraft(prompt)}>
346
+ {prompt}
347
+ </button>
348
+ ))}
349
+ </div>
285
350
  </div>
286
351
  ) : (
287
352
  messages.map((message, index) => (
@@ -292,6 +357,8 @@ function BrainHome({
292
357
  )}
293
358
  </div>
294
359
 
360
+ <BrainCarePanel />
361
+
295
362
  <div className="brain-composer">
296
363
  <textarea
297
364
  value={draft}
@@ -329,6 +396,318 @@ function BrainHome({
329
396
  );
330
397
  }
331
398
 
399
+ function AdminConsole({ onBack }: { onBack: () => void }) {
400
+ const qc = useQueryClient();
401
+ const summaryQ = useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary });
402
+ const statsQ = useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats });
403
+ const usersQ = useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers });
404
+ const auditQ = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
405
+ const securityQ = useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity });
406
+ const securityEventsQ = useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) });
407
+ const policiesQ = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
408
+ const indexQ = useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus });
409
+ const rebuildIndex = useMutation({
410
+ mutationFn: latticeApi.rebuildIndex,
411
+ onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
412
+ });
413
+
414
+ const users = asArray(usersQ.data?.data);
415
+ const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
416
+ const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
417
+ const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
418
+
419
+ return (
420
+ <main className="admin-console" aria-label="Lattice Admin">
421
+ <header className="admin-console-header">
422
+ <button className="admin-back-button" type="button" onClick={onBack}>
423
+ <ArrowLeft className="h-4 w-4" />
424
+ Brain
425
+ </button>
426
+ <div>
427
+ <span>Separate admin workspace</span>
428
+ <h1>Admin Console</h1>
429
+ <p>Users, logs, security, and Brain health stay out of the normal user experience.</p>
430
+ </div>
431
+ </header>
432
+
433
+ <section className="admin-metrics" aria-label="Admin overview">
434
+ <AdminMetric icon={<Users className="h-4 w-4" />} label="Users" value={String(users.length)} detail={sourceLabel(usersQ.data)} />
435
+ <AdminMetric
436
+ icon={<Activity className="h-4 w-4" />}
437
+ label="Recent logs"
438
+ value={String(auditEvents.length + securityEvents.length)}
439
+ detail={sourceLabel(auditQ.data)}
440
+ />
441
+ <AdminMetric
442
+ icon={<ShieldCheck className="h-4 w-4" />}
443
+ label="Security"
444
+ value={adminStatusLabel(securityQ.data?.data, "status") || (securityQ.data?.ok ? "Ready" : "Unavailable")}
445
+ detail={sourceLabel(securityQ.data)}
446
+ />
447
+ <AdminMetric
448
+ icon={<ServerCog className="h-4 w-4" />}
449
+ label="Brain index"
450
+ value={adminStatusLabel(indexQ.data?.data, "status") || (indexQ.data?.ok ? "Indexed" : "Unknown")}
451
+ detail={indexDetail(indexQ.data?.data)}
452
+ />
453
+ </section>
454
+
455
+ <section className="admin-grid">
456
+ <AdminPanel title="User Directory" eyebrow="People">
457
+ <AdminList
458
+ items={users.slice(0, 8)}
459
+ empty="No users reported by the admin API."
460
+ render={(item) => {
461
+ const user = item as ApiRecord;
462
+ return (
463
+ <>
464
+ <strong>{stringValue(user.name || user.email || user.id, "Local user")}</strong>
465
+ <span>{stringValue(user.role || user.status || user.workspace_id, "member")}</span>
466
+ </>
467
+ );
468
+ }}
469
+ />
470
+ </AdminPanel>
471
+
472
+ <AdminPanel title="Activity Logs" eyebrow="Audit">
473
+ <AdminList
474
+ items={auditEvents.slice(0, 8)}
475
+ empty="No recent audit events."
476
+ render={(item) => renderLogRow(item as ApiRecord)}
477
+ />
478
+ </AdminPanel>
479
+
480
+ <AdminPanel title="Security Events" eyebrow="Protection">
481
+ <AdminList
482
+ items={securityEvents.slice(0, 8)}
483
+ empty="No security events reported."
484
+ render={(item) => renderLogRow(item as ApiRecord)}
485
+ />
486
+ </AdminPanel>
487
+
488
+ <AdminPanel title="Brain Operations" eyebrow="Maintenance">
489
+ <div className="admin-operation">
490
+ <div>
491
+ <strong>{indexDetail(indexQ.data?.data)}</strong>
492
+ <span>{summaryText(summaryQ.data?.data) || summaryText(statsQ.data?.data) || "Local Brain services are separated from user chat."}</span>
493
+ </div>
494
+ <Button variant="outline" size="sm" disabled={rebuildIndex.isPending} onClick={() => rebuildIndex.mutate()}>
495
+ <RotateCcw className="h-3.5 w-3.5" />
496
+ {rebuildIndex.isPending ? "Rebuilding" : "Rebuild index"}
497
+ </Button>
498
+ </div>
499
+ <div className="admin-policy-strip">
500
+ {policies.slice(0, 5).map((item, index) => {
501
+ const policy = item as ApiRecord;
502
+ return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.name || policy.id, "Policy")}</span>;
503
+ })}
504
+ {!policies.length ? <span>Policy API quiet</span> : null}
505
+ </div>
506
+ </AdminPanel>
507
+ </section>
508
+ </main>
509
+ );
510
+ }
511
+
512
+ function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
513
+ return (
514
+ <div className="admin-metric">
515
+ <div>{icon}</div>
516
+ <span>{label}</span>
517
+ <strong>{value}</strong>
518
+ <small>{detail}</small>
519
+ </div>
520
+ );
521
+ }
522
+
523
+ function AdminPanel({ eyebrow, title, children }: { eyebrow: string; title: string; children: React.ReactNode }) {
524
+ return (
525
+ <section className="admin-panel">
526
+ <div className="admin-panel-head">
527
+ <span>{eyebrow}</span>
528
+ <h2>{title}</h2>
529
+ </div>
530
+ {children}
531
+ </section>
532
+ );
533
+ }
534
+
535
+ function AdminList({ items, empty, render }: { items: unknown[]; empty: string; render: (item: unknown) => React.ReactNode }) {
536
+ if (!items.length) return <div className="admin-empty">{empty}</div>;
537
+ return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
538
+ }
539
+
540
+ function BrainCarePanel() {
541
+ const qc = useQueryClient();
542
+ const [expanded, setExpanded] = React.useState(false);
543
+ const [archivePath, setArchivePath] = React.useState("");
544
+ const [passphrase, setPassphrase] = React.useState("");
545
+ const [latestResult, setLatestResult] = React.useState<ApiResult | null>(null);
546
+ const portabilityQ = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
547
+ const backupHealthQ = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
548
+ const rememberResult = React.useCallback((result: ApiResult) => setLatestResult(result), []);
549
+
550
+ const exportGraph = useCareMutation(() => latticeApi.graphExport(), undefined, rememberResult);
551
+ const backupGraph = useCareMutation(() => latticeApi.graphBackup(), () => {
552
+ void qc.invalidateQueries({ queryKey: ["backupHealth"] });
553
+ void qc.invalidateQueries({ queryKey: ["portability"] });
554
+ }, rememberResult);
555
+ const archiveBrain = useCareMutation(
556
+ () => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase }),
557
+ () => void qc.invalidateQueries({ queryKey: ["backupHealth"] }),
558
+ rememberResult,
559
+ );
560
+ const inspectArchive = useCareMutation(() => latticeApi.brainArchiveInspect({
561
+ path: archivePath.trim(),
562
+ passphrase: passphrase || null,
563
+ }), undefined, rememberResult);
564
+ const restorePreview = useCareMutation(() => latticeApi.brainArchiveRestore({
565
+ path: archivePath.trim(),
566
+ passphrase,
567
+ dry_run: true,
568
+ confirm: false,
569
+ }), undefined, rememberResult);
570
+
571
+ const portableFormat = portabilityLabel(portabilityQ.data?.data);
572
+ const backupStatus = backupHealthLabel(backupHealthQ.data?.data);
573
+
574
+ return (
575
+ <section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label="Care for my Brain">
576
+ <button
577
+ className="brain-care-summary"
578
+ type="button"
579
+ aria-expanded={expanded}
580
+ aria-controls="brain-care-details"
581
+ onClick={() => setExpanded((value) => !value)}
582
+ >
583
+ <span className="brain-care-summary-main">
584
+ <span><ShieldCheck className="h-3.5 w-3.5" /> Care for my Brain</span>
585
+ <strong>Own it locally. Keep it portable.</strong>
586
+ </span>
587
+ <div className="brain-care-proof" aria-label="Ownership model">
588
+ <span>Private</span>
589
+ <span>{portableFormat}</span>
590
+ <span>{backupStatus}</span>
591
+ </div>
592
+ <ChevronDown className="brain-care-toggle h-4 w-4" aria-hidden="true" />
593
+ </button>
594
+
595
+ {expanded ? (
596
+ <div id="brain-care-details" className="brain-care-details">
597
+ <div className="brain-care-actions">
598
+ <CareButton
599
+ icon={<Download className="h-3.5 w-3.5" />}
600
+ label="Export"
601
+ detail="Take it with you"
602
+ pending={exportGraph.isPending}
603
+ onClick={() => exportGraph.mutate()}
604
+ />
605
+ <CareButton
606
+ icon={<DatabaseBackup className="h-3.5 w-3.5" />}
607
+ label="Backup"
608
+ detail="Save a copy"
609
+ pending={backupGraph.isPending}
610
+ onClick={() => backupGraph.mutate()}
611
+ />
612
+ <CareButton
613
+ icon={<Archive className="h-3.5 w-3.5" />}
614
+ label="Archive"
615
+ detail="Encrypted Brain"
616
+ pending={archiveBrain.isPending}
617
+ disabled={!passphrase.trim()}
618
+ onClick={() => archiveBrain.mutate()}
619
+ />
620
+ </div>
621
+
622
+ <div className="brain-care-archive">
623
+ <input
624
+ value={archivePath}
625
+ onChange={(event) => setArchivePath(event.target.value)}
626
+ placeholder="Paste an archive path to inspect or preview"
627
+ aria-label="Brain archive path"
628
+ />
629
+ <input
630
+ type="password"
631
+ value={passphrase}
632
+ onChange={(event) => setPassphrase(event.target.value)}
633
+ placeholder="Archive passphrase"
634
+ aria-label="Brain archive passphrase"
635
+ />
636
+ <div className="brain-care-archive-actions">
637
+ <Button
638
+ variant="outline"
639
+ size="sm"
640
+ disabled={!archivePath.trim() || inspectArchive.isPending}
641
+ onClick={() => inspectArchive.mutate()}
642
+ >
643
+ <Eye className="h-3.5 w-3.5" /> Inspect
644
+ </Button>
645
+ <Button
646
+ variant="outline"
647
+ size="sm"
648
+ disabled={!archivePath.trim() || !passphrase.trim() || restorePreview.isPending}
649
+ onClick={() => restorePreview.mutate()}
650
+ >
651
+ <RotateCcw className="h-3.5 w-3.5" /> Restore preview
652
+ </Button>
653
+ </div>
654
+ </div>
655
+
656
+ {latestResult ? (
657
+ <div className={`brain-care-result ${latestResult.ok ? "is-ok" : "is-error"}`} role="status">
658
+ {summarizeCareResult(latestResult)}
659
+ </div>
660
+ ) : (
661
+ <p className="brain-care-note">
662
+ Restore preview checks an archive without changing your Brain. Confirmed restore stays in Settings.
663
+ </p>
664
+ )}
665
+ </div>
666
+ ) : null}
667
+ </section>
668
+ );
669
+ }
670
+
671
+ function useCareMutation<T extends ApiResult>(
672
+ mutationFn: () => Promise<T>,
673
+ onSuccess?: () => void,
674
+ onResult?: (result: T) => void,
675
+ ) {
676
+ return useMutation({
677
+ mutationFn,
678
+ onSuccess: (result) => {
679
+ onResult?.(result);
680
+ onSuccess?.();
681
+ },
682
+ });
683
+ }
684
+
685
+ function CareButton({
686
+ icon,
687
+ label,
688
+ detail,
689
+ pending,
690
+ disabled,
691
+ onClick,
692
+ }: {
693
+ icon: React.ReactNode;
694
+ label: string;
695
+ detail: string;
696
+ pending?: boolean;
697
+ disabled?: boolean;
698
+ onClick: () => void;
699
+ }) {
700
+ return (
701
+ <button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
702
+ {icon}
703
+ <span>
704
+ <strong>{pending ? "Working" : label}</strong>
705
+ <small>{detail}</small>
706
+ </span>
707
+ </button>
708
+ );
709
+ }
710
+
332
711
  function DepthEmergence({
333
712
  depth,
334
713
  memories,
@@ -633,6 +1012,70 @@ function currentModelName(data: unknown) {
633
1012
  return firstLoaded ? textValue(firstLoaded, ["name", "id", "model_id"], "local mind") : "local mind";
634
1013
  }
635
1014
 
1015
+ function portabilityLabel(data: unknown) {
1016
+ const record = isRecord(data) ? data : {};
1017
+ return textValue(record, ["archive_format", "format", "graph_schema_version", "schema_version"], ".latticebrain");
1018
+ }
1019
+
1020
+ function backupHealthLabel(data: unknown) {
1021
+ const record = isRecord(data) ? data : {};
1022
+ const count = record.count || record.backups || record.available;
1023
+ if (count !== undefined && count !== null && count !== "") return `${count} backups`;
1024
+ return "Backups ready";
1025
+ }
1026
+
1027
+ function summarizeCareResult(result: ApiResult) {
1028
+ if (!result.ok) return result.error || "Brain care action could not complete.";
1029
+ const data = isRecord(result.data) ? result.data : {};
1030
+ const directMessage = textValue(data, ["message", "status", "path", "archive_path", "backup_path", "export_path"]);
1031
+ if (directMessage) return directMessage;
1032
+ return "Brain care action completed.";
1033
+ }
1034
+
1035
+ function renderLogRow(event: ApiRecord) {
1036
+ const action = stringValue(event.action || event.event || event.type || event.name, "Event");
1037
+ const actor = stringValue(event.actor || event.user || event.user_id || event.workspace_id, "system");
1038
+ const when = stringValue(event.timestamp || event.time || event.created_at || event.ts, "recently");
1039
+ return (
1040
+ <>
1041
+ <strong>{action}</strong>
1042
+ <span>{actor} · {when}</span>
1043
+ </>
1044
+ );
1045
+ }
1046
+
1047
+ function sourceLabel(result?: ApiResult<unknown>) {
1048
+ if (!result) return "Loading";
1049
+ return result.ok ? "Live" : result.error || "Unavailable";
1050
+ }
1051
+
1052
+ function adminStatusLabel(data: unknown, key: string) {
1053
+ const record = isRecord(data) ? data : {};
1054
+ return textValue(record, [key, "health", "state", "overall_status"]);
1055
+ }
1056
+
1057
+ function indexDetail(data: unknown) {
1058
+ const record = isRecord(data) ? data : {};
1059
+ const docs = record.documents ?? record.document_count ?? record.docs;
1060
+ const chunks = record.chunks ?? record.chunk_count ?? record.vectors;
1061
+ if (docs !== undefined || chunks !== undefined) {
1062
+ return `${stringValue(docs, "0")} docs · ${stringValue(chunks, "0")} chunks`;
1063
+ }
1064
+ return textValue(record, ["message", "detail", "status"], "Index status ready");
1065
+ }
1066
+
1067
+ function summaryText(data: unknown) {
1068
+ const record = isRecord(data) ? data : {};
1069
+ return textValue(record, ["summary", "message", "status", "detail"]);
1070
+ }
1071
+
1072
+ function stringValue(value: unknown, fallback = "") {
1073
+ if (typeof value === "string" && value.trim()) return value;
1074
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1075
+ if (typeof value === "boolean") return value ? "true" : "false";
1076
+ return fallback;
1077
+ }
1078
+
636
1079
  function fileToDataUrl(file: File) {
637
1080
  return new Promise<string>((resolve, reject) => {
638
1081
  const reader = new FileReader();
@@ -417,12 +417,14 @@ export const latticeApi = {
417
417
  computerMemory: () => get("/workspace/computer-memory", {}),
418
418
  setComputerMemory: (enabled: boolean) => post("/workspace/computer-memory", { enabled, consent: { approved: enabled } }, {}),
419
419
  adminSummary: () => get("/admin/summary", {}),
420
+ adminStats: () => get("/admin/stats", {}),
420
421
  adminUsers: () => get("/admin/users", []),
421
422
  adminAudit: () => get("/admin/audit", { recent_events: [] }),
422
423
  adminRoles: () => get("/admin/roles", { roles: [] }),
423
424
  adminPolicies: () => get("/admin/policies", { policies: [] }),
424
425
  adminProductHardening: () => get("/admin/product-hardening", {}),
425
426
  adminSecurity: () => get("/admin/security/overview", {}),
427
+ adminSecurityEvents: (limit = 50) => get("/admin/security/events", { events: [] }, { limit }),
426
428
  vpcStatus: () => get("/vpc/status", {}),
427
429
  toolPermissions: () => get("/tools/permissions", { permissions: [] }),
428
430
  };
@@ -178,7 +178,11 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
178
178
  return (
179
179
  <div>
180
180
  <div className="ritual-title">Welcome to your mind.</div>
181
- <div className="ritual-subtitle">This is private. Everything stays on your machine. Begin by opening a local profile for your Brain.</div>
181
+ <div className="ritual-subtitle">
182
+ Models will change. Your knowledge should not. Lattice keeps your documents, conversations, decisions, and context together as a private Brain you own.
183
+ </div>
184
+
185
+ <ProductPromise />
182
186
 
183
187
  <form onSubmit={submit} className="ritual-card" style={{ maxWidth: 420, margin: "0 auto" }}>
184
188
  <div style={{ display: "grid", gap: "0.85rem" }}>
@@ -202,13 +206,32 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
202
206
  {busy ? "Opening the Brain..." : "Open my Brain"}
203
207
  </Button>
204
208
  <div style={{ fontSize: "0.75rem", color: "hsl(var(--fg-muted))", marginTop: "0.6rem" }}>
205
- Your first conversation will feel like coming home.
209
+ Local profile first. Model choice comes later.
206
210
  </div>
207
211
  </form>
208
212
  </div>
209
213
  );
210
214
  }
211
215
 
216
+ function ProductPromise() {
217
+ return (
218
+ <div className="ritual-promise" aria-label="Lattice AI product promise">
219
+ <div>
220
+ <span>Durable knowledge</span>
221
+ <strong>Your work becomes long-lived context.</strong>
222
+ </div>
223
+ <div>
224
+ <span>Replaceable models</span>
225
+ <strong>The model is a voice, not the asset.</strong>
226
+ </div>
227
+ <div>
228
+ <span>User ownership</span>
229
+ <strong>Back up, restore, and move your Brain.</strong>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
212
235
  function AnalysisScreen({
213
236
  analysis,
214
237
  error,
@@ -223,7 +246,7 @@ function AnalysisScreen({
223
246
  <div>
224
247
  <div className="ritual-title">Understanding your home.</div>
225
248
  <div className="ritual-subtitle">
226
- We are learning what kind of mind this computer can support. Your Brain will live here quietly, privately, powerfully.
249
+ Lattice is checking what this computer can support so your Brain can run locally instead of turning your memories into a cloud dependency.
227
250
  </div>
228
251
 
229
252
  <div className="ritual-fact-grid">
@@ -273,7 +296,7 @@ function RecommendationScreen({
273
296
  <div>
274
297
  <div className="ritual-title">How shall your mind think today?</div>
275
298
  <div className="ritual-subtitle">
276
- A short, honest list chosen for the computer you are on right now. Pick the one that feels right.
299
+ The model is the current voice of your Brain. You can replace it later; your knowledge stays.
277
300
  </div>
278
301
 
279
302
  <div style={{ maxWidth: 560, margin: "0 auto" }}>
@@ -365,7 +388,7 @@ function InstallScreen({
365
388
  <div className="ritual-title">Bring this mind home.</div>
366
389
  <div className="ritual-subtitle">
367
390
  <strong>{model.shortName}</strong> — {model.reason}.<br />
368
- We will download (if needed), validate, and load it. Nothing happens without your explicit consent.
391
+ This gives your Brain a local voice. Download, validation, and loading happen only with your consent.
369
392
  </div>
370
393
 
371
394
  {/* Living Brain reacts to the ceremony of installation */}