ltcai 4.7.0 → 5.0.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.
@@ -9,6 +9,7 @@ import {
9
9
  Download,
10
10
  Eye,
11
11
  ImagePlus,
12
+ ListFilter,
12
13
  RotateCcw,
13
14
  Search,
14
15
  Send,
@@ -16,15 +17,17 @@ import {
16
17
  ShieldCheck,
17
18
  Users,
18
19
  } from "lucide-react";
19
- import { latticeApi, type ApiResult } from "@/api/client";
20
+ import { latticeApi, type AdminAuditFilters, type ApiResult } from "@/api/client";
20
21
  import { Button } from "@/components/ui/button";
21
22
  import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
22
23
  import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
23
24
  import { useAppStore } from "@/store/appStore";
24
25
  import { asArray } from "@/lib/utils";
26
+ import { LANGUAGE_LABELS, t, type Language } from "@/i18n";
25
27
 
26
28
  type ApiRecord = Record<string, unknown>;
27
29
  type BrainDepth = 1 | 2 | 3 | 4 | 5;
30
+ type AdminFilterState = Required<Pick<AdminAuditFilters, "q" | "actor" | "action" | "severity">> & { limit: number };
28
31
 
29
32
  type Message = {
30
33
  role: "user" | "assistant";
@@ -66,21 +69,17 @@ const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
66
69
  { level: 5, label: "Knowledge Graph", state: "synthesizing" },
67
70
  ];
68
71
 
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
-
75
72
  export default function App() {
76
73
  const theme = useAppStore((state) => state.theme);
74
+ const language = useAppStore((state) => state.language);
77
75
  const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
78
76
  const route = useHashRoute();
79
77
  const { state: brainState, intensity, setBrain } = useBrainState();
80
78
 
81
79
  React.useEffect(() => {
82
80
  document.documentElement.dataset.theme = theme;
83
- }, [theme]);
81
+ document.documentElement.lang = language === "ko" ? "ko" : "en";
82
+ }, [theme, language]);
84
83
 
85
84
  React.useEffect(() => {
86
85
  const onKey = (event: KeyboardEvent) => {
@@ -151,6 +150,7 @@ function BrainHome({
151
150
  onBrainChange: (state: BrainState, intensity?: number) => void;
152
151
  }) {
153
152
  const qc = useQueryClient();
153
+ const language = useAppStore((state) => state.language);
154
154
  const [messages, setMessages] = React.useState<Message[]>([]);
155
155
  const [draft, setDraft] = React.useState("");
156
156
  const [imageData, setImageData] = React.useState<string | null>(null);
@@ -159,6 +159,7 @@ function BrainHome({
159
159
  const [explorationDepth, setExplorationDepth] = React.useState<BrainDepth>(1);
160
160
  const [graphSearch, setGraphSearch] = React.useState("");
161
161
  const [selectedGraphId, setSelectedGraphId] = React.useState<string | null>(null);
162
+ const [memoryFeedback, setMemoryFeedback] = React.useState<string | null>(null);
162
163
  const streamRef = React.useRef<HTMLDivElement>(null);
163
164
  const recallTimerRef = React.useRef<number | null>(null);
164
165
 
@@ -182,6 +183,14 @@ function BrainHome({
182
183
  );
183
184
  const modelName = React.useMemo(() => currentModelName(modelsQ.data?.data), [modelsQ.data]);
184
185
  const currentDepth = DEPTHS[explorationDepth - 1];
186
+ const starterPrompts = React.useMemo(
187
+ () => [
188
+ t(language, "brain.prompt.remember"),
189
+ t(language, "brain.prompt.know"),
190
+ t(language, "brain.prompt.plan"),
191
+ ],
192
+ [language],
193
+ );
185
194
 
186
195
  React.useEffect(() => {
187
196
  if (streaming) onBrainChange("thinking", 0.94);
@@ -210,6 +219,7 @@ function BrainHome({
210
219
  setDraft("");
211
220
  setImageData(null);
212
221
  setStreaming(true);
222
+ setMemoryFeedback(null);
213
223
  onBrainChange("thinking", 0.96);
214
224
 
215
225
  try {
@@ -238,6 +248,8 @@ function BrainHome({
238
248
  next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
239
249
  return next;
240
250
  });
251
+ } else {
252
+ setMemoryFeedback(t(language, "brain.saved", { topics: knowledgeConcepts.length, memories: memoryFragments.length }));
241
253
  }
242
254
  } finally {
243
255
  setStreaming(false);
@@ -257,6 +269,13 @@ function BrainHome({
257
269
  });
258
270
  }
259
271
 
272
+ function jumpToDepth(next: BrainDepth) {
273
+ setExplorationDepth(next);
274
+ const nextDepth = DEPTHS[next - 1];
275
+ onBrainChange(nextDepth.state, next === 1 ? 0.58 : 0.66 + next * 0.06);
276
+ if (next >= 2) triggerBrainRecall();
277
+ }
278
+
260
279
  function surface() {
261
280
  setExplorationDepth(1);
262
281
  setSelectedGraphId(null);
@@ -269,7 +288,7 @@ function BrainHome({
269
288
  setExplorationDepth((depth) => Math.max(depth, 2) as BrainDepth);
270
289
  setMessages((items) => [
271
290
  ...items,
272
- { role: "assistant", content: `I am recalling ${fragment.kind.toLowerCase()}: ${fragment.title}` },
291
+ { role: "assistant", content: t(language, "brain.recalled", { title: fragment.title }) },
273
292
  ]);
274
293
  }
275
294
 
@@ -287,8 +306,15 @@ function BrainHome({
287
306
  />
288
307
 
289
308
  <div className="brain-depth-badge" aria-live="polite">
290
- <span>Level {explorationDepth}</span>
291
- <strong>{currentDepth.label}</strong>
309
+ <span>{t(language, "brain.level")} {explorationDepth}</span>
310
+ <strong>{t(language, `brain.depth.${explorationDepth}`)}</strong>
311
+ </div>
312
+
313
+ <div className="brain-depth-actions" aria-label="Brain quick views">
314
+ <button type="button" className={explorationDepth === 2 ? "is-active" : ""} onClick={() => jumpToDepth(2)}>{t(language, "brain.view.memories")}</button>
315
+ <button type="button" className={explorationDepth === 3 ? "is-active" : ""} onClick={() => jumpToDepth(3)}>{t(language, "brain.view.topics")}</button>
316
+ <button type="button" className={explorationDepth === 4 ? "is-active" : ""} onClick={() => jumpToDepth(4)}>{t(language, "brain.view.relationships")}</button>
317
+ <button type="button" className={explorationDepth === 5 ? "is-active" : ""} onClick={() => jumpToDepth(5)}>{t(language, "brain.view.graph")}</button>
292
318
  </div>
293
319
 
294
320
  <div className="brain-field-layer" aria-hidden={explorationDepth < 2}>
@@ -308,7 +334,7 @@ function BrainHome({
308
334
 
309
335
  {explorationDepth > 1 ? (
310
336
  <button className="brain-surface-control" type="button" onClick={surface}>
311
- Surface
337
+ {t(language, "brain.surface")}
312
338
  </button>
313
339
  ) : null}
314
340
  </div>
@@ -317,31 +343,35 @@ function BrainHome({
317
343
  <section className="brain-conversation" aria-label="Conversation">
318
344
  <div className="brain-conversation-header">
319
345
  <div>
320
- <h1>Lattice Brain</h1>
321
- <span>{currentDepth.label}</span>
346
+ <h1>{t(language, "brain.title")}</h1>
347
+ <span>{t(language, `brain.depth.${explorationDepth}`)}</span>
322
348
  </div>
349
+ <LanguageSwitcher compact />
323
350
  <div className="brain-ownership-strip" aria-label="Brain ownership guarantees">
324
- <span>Local-first</span>
325
- <span>Portable</span>
326
- <span>Private</span>
351
+ <span>{t(language, "brain.local")}</span>
352
+ <span>{t(language, "brain.portable")}</span>
353
+ <span>{t(language, "brain.private")}</span>
327
354
  </div>
328
355
  <div>{modelName}</div>
329
356
  <button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
330
357
  <ShieldCheck className="h-3.5 w-3.5" />
331
- Admin
358
+ {t(language, "brain.admin")}
332
359
  </button>
333
360
  </div>
334
361
 
335
362
  <div ref={streamRef} className="brain-stream">
363
+ <BrainOverviewPanel
364
+ memories={memoryFragments}
365
+ concepts={knowledgeConcepts}
366
+ onOpenDepth={jumpToDepth}
367
+ />
336
368
  {messages.length === 0 ? (
337
369
  <div className="mind-empty">
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>
370
+ <div className="mind-empty-kicker">{t(language, "brain.empty.kicker")}</div>
371
+ <div className="mind-empty-title">{t(language, "brain.empty.title")}</div>
372
+ <p>{t(language, "brain.empty.body")}</p>
343
373
  <div className="mind-empty-prompts" aria-label="Starter prompts">
344
- {STARTER_PROMPTS.map((prompt) => (
374
+ {starterPrompts.map((prompt) => (
345
375
  <button key={prompt} type="button" onClick={() => setDraft(prompt)}>
346
376
  {prompt}
347
377
  </button>
@@ -357,7 +387,9 @@ function BrainHome({
357
387
  )}
358
388
  </div>
359
389
 
360
- <BrainCarePanel />
390
+ {memoryFeedback ? <div className="brain-save-feedback" role="status">{memoryFeedback}</div> : null}
391
+
392
+ <BrainCarePanel language={language} />
361
393
 
362
394
  <div className="brain-composer">
363
395
  <textarea
@@ -369,12 +401,12 @@ function BrainHome({
369
401
  void send();
370
402
  }
371
403
  }}
372
- placeholder="Talk to your Brain..."
404
+ placeholder={t(language, "brain.placeholder")}
373
405
  />
374
406
  <div className="brain-composer-actions">
375
407
  <label className="brain-image-input">
376
408
  <ImagePlus className="h-3.5 w-3.5" />
377
- <span>Image</span>
409
+ <span>{t(language, "brain.image")}</span>
378
410
  <input
379
411
  type="file"
380
412
  accept="image/*"
@@ -385,9 +417,9 @@ function BrainHome({
385
417
  }}
386
418
  />
387
419
  </label>
388
- {imageData ? <span className="brain-quiet-success">Image attached</span> : null}
420
+ {imageData ? <span className="brain-quiet-success">{t(language, "brain.imageAttached")}</span> : null}
389
421
  <Button onClick={() => void send()} disabled={!draft.trim() || streaming} className="rounded-full px-5">
390
- <Send className="h-4 w-4" /> Send
422
+ <Send className="h-4 w-4" /> {t(language, "brain.send")}
391
423
  </Button>
392
424
  </div>
393
425
  </div>
@@ -396,16 +428,32 @@ function BrainHome({
396
428
  );
397
429
  }
398
430
 
431
+ function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
432
+ const language = useAppStore((state) => state.language);
433
+ const setLanguage = useAppStore((state) => state.setLanguage);
434
+
435
+ return (
436
+ <div className={compact ? "language-switcher compact" : "language-switcher"} aria-label={t(language, "language.label")}>
437
+ {(["ko", "en"] as Language[]).map((item) => (
438
+ <button
439
+ key={item}
440
+ type="button"
441
+ className={language === item ? "is-active" : ""}
442
+ onClick={() => setLanguage(item)}
443
+ aria-pressed={language === item}
444
+ >
445
+ {LANGUAGE_LABELS[item]}
446
+ </button>
447
+ ))}
448
+ </div>
449
+ );
450
+ }
451
+
399
452
  function AdminConsole({ onBack }: { onBack: () => void }) {
400
453
  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 });
454
+ const language = useAppStore((state) => state.language);
455
+ const [filters, setFilters] = React.useState<AdminFilterState>({ q: "", actor: "", action: "", severity: "", limit: 50 });
456
+ const { summaryQ, statsQ, usersQ, auditQ, securityQ, securityEventsQ, policiesQ, rolesQ, retentionQ, indexQ } = useAdminConsoleData(filters);
409
457
  const rebuildIndex = useMutation({
410
458
  mutationFn: latticeApi.rebuildIndex,
411
459
  onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
@@ -415,19 +463,22 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
415
463
  const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
416
464
  const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
417
465
  const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
466
+ const roles = asArray((rolesQ.data?.data as ApiRecord | undefined)?.roles);
467
+ const retention = (retentionQ.data?.data || {}) as ApiRecord;
418
468
 
419
469
  return (
420
470
  <main className="admin-console" aria-label="Lattice Admin">
421
471
  <header className="admin-console-header">
422
472
  <button className="admin-back-button" type="button" onClick={onBack}>
423
473
  <ArrowLeft className="h-4 w-4" />
424
- Brain
474
+ {t(language, "admin.back")}
425
475
  </button>
426
476
  <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>
477
+ <span>{t(language, "admin.kicker")}</span>
478
+ <h1>{t(language, "admin.title")}</h1>
479
+ <p>{t(language, "admin.body")}</p>
430
480
  </div>
481
+ <LanguageSwitcher compact />
431
482
  </header>
432
483
 
433
484
  <section className="admin-metrics" aria-label="Admin overview">
@@ -469,7 +520,24 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
469
520
  />
470
521
  </AdminPanel>
471
522
 
523
+ <AdminPanel title="Role Permissions" eyebrow="Access">
524
+ <AdminList
525
+ items={roles.slice(0, 6)}
526
+ empty="No role matrix reported."
527
+ render={(item) => {
528
+ const role = item as ApiRecord;
529
+ return (
530
+ <>
531
+ <strong>{stringValue(role.role, "role")} · {stringValue(role.members, "0")} users</strong>
532
+ <span>{asArray(role.caps).slice(0, 4).map((cap) => stringValue(cap, "")).filter(Boolean).join(", ") || "No caps"}</span>
533
+ </>
534
+ );
535
+ }}
536
+ />
537
+ </AdminPanel>
538
+
472
539
  <AdminPanel title="Activity Logs" eyebrow="Audit">
540
+ <AdminLogFilters filters={filters} onChange={setFilters} matched={(auditQ.data?.data as ApiRecord | undefined)?.filters as ApiRecord | undefined} />
473
541
  <AdminList
474
542
  items={auditEvents.slice(0, 8)}
475
543
  empty="No recent audit events."
@@ -499,16 +567,82 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
499
567
  <div className="admin-policy-strip">
500
568
  {policies.slice(0, 5).map((item, index) => {
501
569
  const policy = item as ApiRecord;
502
- return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.name || policy.id, "Policy")}</span>;
570
+ return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.label || policy.name || policy.id, "Policy")}</span>;
503
571
  })}
504
572
  {!policies.length ? <span>Policy API quiet</span> : null}
505
573
  </div>
574
+ <div className="admin-retention">
575
+ <strong>{stringValue(retention.retention_days, "90")} day retention</strong>
576
+ <span>{stringValue(retention.retained_events, "0")} retained · {stringValue(retention.prune_candidates, "0")} ready for export/prune review</span>
577
+ </div>
506
578
  </AdminPanel>
507
579
  </section>
508
580
  </main>
509
581
  );
510
582
  }
511
583
 
584
+ function useAdminConsoleData(filters: AdminFilterState) {
585
+ const auditFilters = React.useMemo<AdminAuditFilters>(() => ({
586
+ q: filters.q || undefined,
587
+ actor: filters.actor || undefined,
588
+ action: filters.action || undefined,
589
+ severity: filters.severity || undefined,
590
+ limit: filters.limit,
591
+ }), [filters]);
592
+
593
+ return {
594
+ summaryQ: useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary }),
595
+ statsQ: useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats }),
596
+ usersQ: useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers }),
597
+ auditQ: useQuery({ queryKey: ["adminAudit", auditFilters], queryFn: () => latticeApi.adminAudit(auditFilters) }),
598
+ securityQ: useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity }),
599
+ securityEventsQ: useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) }),
600
+ policiesQ: useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies }),
601
+ rolesQ: useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles }),
602
+ retentionQ: useQuery({ queryKey: ["adminLogRetention"], queryFn: latticeApi.adminLogRetention }),
603
+ indexQ: useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus }),
604
+ };
605
+ }
606
+
607
+ function AdminLogFilters({
608
+ filters,
609
+ matched,
610
+ onChange,
611
+ }: {
612
+ filters: AdminFilterState;
613
+ matched?: ApiRecord;
614
+ onChange: React.Dispatch<React.SetStateAction<AdminFilterState>>;
615
+ }) {
616
+ return (
617
+ <div className="admin-log-filters" aria-label="Audit log filters">
618
+ <label>
619
+ <Search className="h-3.5 w-3.5" />
620
+ <input
621
+ value={filters.q}
622
+ onChange={(event) => onChange((current) => ({ ...current, q: event.target.value }))}
623
+ placeholder="Search logs"
624
+ aria-label="Search audit logs"
625
+ />
626
+ </label>
627
+ <label>
628
+ <ListFilter className="h-3.5 w-3.5" />
629
+ <select
630
+ value={filters.severity}
631
+ onChange={(event) => onChange((current) => ({ ...current, severity: event.target.value }))}
632
+ aria-label="Filter by severity"
633
+ >
634
+ <option value="">All severities</option>
635
+ <option value="informational">Informational</option>
636
+ <option value="notice">Notice</option>
637
+ <option value="warning">Warning</option>
638
+ <option value="high">High</option>
639
+ </select>
640
+ </label>
641
+ <span>{stringValue(matched?.matched_events, "0")} matched</span>
642
+ </div>
643
+ );
644
+ }
645
+
512
646
  function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
513
647
  return (
514
648
  <div className="admin-metric">
@@ -537,7 +671,7 @@ function AdminList({ items, empty, render }: { items: unknown[]; empty: string;
537
671
  return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
538
672
  }
539
673
 
540
- function BrainCarePanel() {
674
+ function BrainCarePanel({ language }: { language: Language }) {
541
675
  const qc = useQueryClient();
542
676
  const [expanded, setExpanded] = React.useState(false);
543
677
  const [archivePath, setArchivePath] = React.useState("");
@@ -572,7 +706,7 @@ function BrainCarePanel() {
572
706
  const backupStatus = backupHealthLabel(backupHealthQ.data?.data);
573
707
 
574
708
  return (
575
- <section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label="Care for my Brain">
709
+ <section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label={t(language, "care.title")}>
576
710
  <button
577
711
  className="brain-care-summary"
578
712
  type="button"
@@ -581,11 +715,11 @@ function BrainCarePanel() {
581
715
  onClick={() => setExpanded((value) => !value)}
582
716
  >
583
717
  <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>
718
+ <span><ShieldCheck className="h-3.5 w-3.5" /> {t(language, "care.title")}</span>
719
+ <strong>{t(language, "care.subtitle")}</strong>
586
720
  </span>
587
721
  <div className="brain-care-proof" aria-label="Ownership model">
588
- <span>Private</span>
722
+ <span>{t(language, "care.private")}</span>
589
723
  <span>{portableFormat}</span>
590
724
  <span>{backupStatus}</span>
591
725
  </div>
@@ -597,22 +731,25 @@ function BrainCarePanel() {
597
731
  <div className="brain-care-actions">
598
732
  <CareButton
599
733
  icon={<Download className="h-3.5 w-3.5" />}
600
- label="Export"
601
- detail="Take it with you"
734
+ label={t(language, "care.export")}
735
+ detail={t(language, "care.export.detail")}
736
+ pendingLabel={t(language, "care.working")}
602
737
  pending={exportGraph.isPending}
603
738
  onClick={() => exportGraph.mutate()}
604
739
  />
605
740
  <CareButton
606
741
  icon={<DatabaseBackup className="h-3.5 w-3.5" />}
607
- label="Backup"
608
- detail="Save a copy"
742
+ label={t(language, "care.backup")}
743
+ detail={t(language, "care.backup.detail")}
744
+ pendingLabel={t(language, "care.working")}
609
745
  pending={backupGraph.isPending}
610
746
  onClick={() => backupGraph.mutate()}
611
747
  />
612
748
  <CareButton
613
749
  icon={<Archive className="h-3.5 w-3.5" />}
614
- label="Archive"
615
- detail="Encrypted Brain"
750
+ label={t(language, "care.archive")}
751
+ detail={t(language, "care.archive.detail")}
752
+ pendingLabel={t(language, "care.working")}
616
753
  pending={archiveBrain.isPending}
617
754
  disabled={!passphrase.trim()}
618
755
  onClick={() => archiveBrain.mutate()}
@@ -623,15 +760,15 @@ function BrainCarePanel() {
623
760
  <input
624
761
  value={archivePath}
625
762
  onChange={(event) => setArchivePath(event.target.value)}
626
- placeholder="Paste an archive path to inspect or preview"
627
- aria-label="Brain archive path"
763
+ placeholder={t(language, "care.path.placeholder")}
764
+ aria-label={t(language, "care.path.label")}
628
765
  />
629
766
  <input
630
767
  type="password"
631
768
  value={passphrase}
632
769
  onChange={(event) => setPassphrase(event.target.value)}
633
- placeholder="Archive passphrase"
634
- aria-label="Brain archive passphrase"
770
+ placeholder={t(language, "care.passphrase.placeholder")}
771
+ aria-label={t(language, "care.passphrase.label")}
635
772
  />
636
773
  <div className="brain-care-archive-actions">
637
774
  <Button
@@ -640,7 +777,7 @@ function BrainCarePanel() {
640
777
  disabled={!archivePath.trim() || inspectArchive.isPending}
641
778
  onClick={() => inspectArchive.mutate()}
642
779
  >
643
- <Eye className="h-3.5 w-3.5" /> Inspect
780
+ <Eye className="h-3.5 w-3.5" /> {t(language, "care.inspect")}
644
781
  </Button>
645
782
  <Button
646
783
  variant="outline"
@@ -648,7 +785,7 @@ function BrainCarePanel() {
648
785
  disabled={!archivePath.trim() || !passphrase.trim() || restorePreview.isPending}
649
786
  onClick={() => restorePreview.mutate()}
650
787
  >
651
- <RotateCcw className="h-3.5 w-3.5" /> Restore preview
788
+ <RotateCcw className="h-3.5 w-3.5" /> {t(language, "care.restorePreview")}
652
789
  </Button>
653
790
  </div>
654
791
  </div>
@@ -659,7 +796,7 @@ function BrainCarePanel() {
659
796
  </div>
660
797
  ) : (
661
798
  <p className="brain-care-note">
662
- Restore preview checks an archive without changing your Brain. Confirmed restore stays in Settings.
799
+ {t(language, "care.note")}
663
800
  </p>
664
801
  )}
665
802
  </div>
@@ -686,6 +823,7 @@ function CareButton({
686
823
  icon,
687
824
  label,
688
825
  detail,
826
+ pendingLabel,
689
827
  pending,
690
828
  disabled,
691
829
  onClick,
@@ -693,6 +831,7 @@ function CareButton({
693
831
  icon: React.ReactNode;
694
832
  label: string;
695
833
  detail: string;
834
+ pendingLabel: string;
696
835
  pending?: boolean;
697
836
  disabled?: boolean;
698
837
  onClick: () => void;
@@ -701,7 +840,7 @@ function CareButton({
701
840
  <button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
702
841
  {icon}
703
842
  <span>
704
- <strong>{pending ? "Working" : label}</strong>
843
+ <strong>{pending ? pendingLabel : label}</strong>
705
844
  <small>{detail}</small>
706
845
  </span>
707
846
  </button>
@@ -757,6 +896,76 @@ function DepthEmergence({
757
896
  );
758
897
  }
759
898
 
899
+ function BrainOverviewPanel({
900
+ memories,
901
+ concepts,
902
+ onOpenDepth,
903
+ }: {
904
+ memories: MemoryFragment[];
905
+ concepts: KnowledgeConcept[];
906
+ onOpenDepth: (depth: BrainDepth) => void;
907
+ }) {
908
+ const language = useAppStore((state) => state.language);
909
+ const recent = memories.slice(0, 3);
910
+ const older = memories.slice(3, 6);
911
+ const topics = concepts.slice(0, 4);
912
+
913
+ return (
914
+ <section className="brain-overview-panel" aria-label="Brain overview">
915
+ <div className="brain-overview-head">
916
+ <div>
917
+ <span>{t(language, "brain.overview.kicker")}</span>
918
+ <strong>{t(language, "brain.overview.title")}</strong>
919
+ </div>
920
+ <button type="button" onClick={() => onOpenDepth(5)}>{t(language, "brain.overview.graph")}</button>
921
+ </div>
922
+ <div className="brain-overview-grid">
923
+ <BrainOverviewColumn
924
+ title={t(language, "brain.overview.recent")}
925
+ empty={t(language, "brain.overview.recentEmpty")}
926
+ items={recent.map((memory) => memory.title)}
927
+ onOpen={() => onOpenDepth(2)}
928
+ />
929
+ <BrainOverviewColumn
930
+ title={t(language, "brain.overview.older")}
931
+ empty={t(language, "brain.overview.olderEmpty")}
932
+ items={older.map((memory) => memory.title)}
933
+ onOpen={() => onOpenDepth(2)}
934
+ />
935
+ <BrainOverviewColumn
936
+ title={t(language, "brain.overview.topics")}
937
+ empty={t(language, "brain.overview.topicsEmpty")}
938
+ items={topics.map((concept) => concept.label)}
939
+ onOpen={() => onOpenDepth(3)}
940
+ />
941
+ </div>
942
+ </section>
943
+ );
944
+ }
945
+
946
+ function BrainOverviewColumn({
947
+ title,
948
+ empty,
949
+ items,
950
+ onOpen,
951
+ }: {
952
+ title: string;
953
+ empty: string;
954
+ items: string[];
955
+ onOpen: () => void;
956
+ }) {
957
+ return (
958
+ <button type="button" className="brain-overview-column" onClick={onOpen}>
959
+ <span>{title}</span>
960
+ {items.length ? (
961
+ items.slice(0, 3).map((item) => <strong key={item}>{item}</strong>)
962
+ ) : (
963
+ <em>{empty}</em>
964
+ )}
965
+ </button>
966
+ );
967
+ }
968
+
760
969
  function MemoryLayer({
761
970
  memories,
762
971
  depth,
@@ -869,6 +1078,7 @@ function EmergentKnowledgeGraph({
869
1078
  onSearch: (value: string) => void;
870
1079
  onSelect: (id: string | null) => void;
871
1080
  }) {
1081
+ const language = useAppStore((state) => state.language);
872
1082
  const query = search.trim().toLowerCase();
873
1083
  const visibleNodes = React.useMemo(() => {
874
1084
  const filtered = model.nodes.filter((node) => {
@@ -936,7 +1146,7 @@ function EmergentKnowledgeGraph({
936
1146
  ))}
937
1147
  </div>
938
1148
  ) : (
939
- <div className="brain-graph-empty">No matching knowledge yet</div>
1149
+ <div className="brain-graph-empty">{t(language, "brain.graph.empty")}</div>
940
1150
  )}
941
1151
 
942
1152
  <div className="brain-graph-focus">
@@ -944,10 +1154,11 @@ function EmergentKnowledgeGraph({
944
1154
  <>
945
1155
  <span>{selected.type}</span>
946
1156
  <strong>{selected.label}</strong>
947
- <p>{selected.summary || "This concept is part of the deepest knowledge layer."}</p>
1157
+ <p>{selected.summary || t(language, "brain.graph.summaryFallback")}</p>
1158
+ <p>{t(language, "brain.graph.focused")}</p>
948
1159
  </>
949
1160
  ) : (
950
- <p>Capture documents, conversations, or projects to grow the graph.</p>
1161
+ <p>{t(language, "brain.graph.emptyFocus")}</p>
951
1162
  )}
952
1163
  </div>
953
1164
  </section>
@@ -12,6 +12,13 @@ export type ApiResult<T = unknown> = {
12
12
 
13
13
  type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
14
14
  type Query = Record<string, string | number | boolean | null | undefined>;
15
+ export type AdminAuditFilters = {
16
+ q?: string;
17
+ actor?: string;
18
+ action?: string;
19
+ severity?: string;
20
+ limit?: number;
21
+ };
15
22
 
16
23
  const TIMEOUT_MS = 10_000;
17
24
  const clients = new Map<string, ReturnType<typeof createClient<paths>>>();
@@ -419,9 +426,10 @@ export const latticeApi = {
419
426
  adminSummary: () => get("/admin/summary", {}),
420
427
  adminStats: () => get("/admin/stats", {}),
421
428
  adminUsers: () => get("/admin/users", []),
422
- adminAudit: () => get("/admin/audit", { recent_events: [] }),
429
+ adminAudit: (filters?: AdminAuditFilters) => get("/admin/audit", { recent_events: [], filters: {} }, filters),
423
430
  adminRoles: () => get("/admin/roles", { roles: [] }),
424
431
  adminPolicies: () => get("/admin/policies", { policies: [] }),
432
+ adminLogRetention: () => get("/admin/log-retention", {}),
425
433
  adminProductHardening: () => get("/admin/product-hardening", {}),
426
434
  adminSecurity: () => get("/admin/security/overview", {}),
427
435
  adminSecurityEvents: (limit = 50) => get("/admin/security/events", { events: [] }, { limit }),