ltcai 7.0.0 → 7.1.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.
Files changed (61) hide show
  1. package/README.md +20 -20
  2. package/docs/CHANGELOG.md +23 -0
  3. package/frontend/src/App.tsx +80 -0
  4. package/frontend/src/api/client.ts +6 -0
  5. package/frontend/src/components/AdminAccessGate.tsx +70 -0
  6. package/frontend/src/components/BrainConversation.tsx +232 -8
  7. package/frontend/src/components/FeedbackState.tsx +45 -0
  8. package/frontend/src/components/WorkspaceProfileSwitcher.tsx +176 -0
  9. package/frontend/src/components/onboarding/AnalysisScreen.tsx +20 -12
  10. package/frontend/src/components/onboarding/InstallScreen.tsx +55 -0
  11. package/frontend/src/components/onboarding/RecommendationScreen.tsx +55 -5
  12. package/frontend/src/components/onboarding/recommendationModel.ts +59 -2
  13. package/frontend/src/features/brain/BrainConversation.tsx +232 -19
  14. package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
  15. package/frontend/src/features/brain/BrainHome.tsx +193 -1
  16. package/frontend/src/features/brain/brainData.ts +25 -1
  17. package/frontend/src/features/brain/graphLayout.ts +12 -1
  18. package/frontend/src/features/brain/types.ts +41 -0
  19. package/frontend/src/i18n.ts +304 -0
  20. package/frontend/src/store/appStore.ts +15 -0
  21. package/frontend/src/styles.css +1130 -0
  22. package/lattice_brain/__init__.py +1 -1
  23. package/lattice_brain/runtime/multi_agent.py +1 -1
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/workspace.py +59 -0
  26. package/latticeai/core/marketplace.py +1 -1
  27. package/latticeai/core/workspace_os.py +1 -1
  28. package/package.json +1 -1
  29. package/src-tauri/Cargo.lock +1 -1
  30. package/src-tauri/Cargo.toml +1 -1
  31. package/src-tauri/tauri.conf.json +1 -1
  32. package/static/app/asset-manifest.json +28 -28
  33. package/static/app/assets/Act-BHEb8bAM.js +2 -0
  34. package/static/app/assets/{Act-DhLaHnrT.js.map → Act-BHEb8bAM.js.map} +1 -1
  35. package/static/app/assets/Brain-yvR7xkrV.js +322 -0
  36. package/static/app/assets/Brain-yvR7xkrV.js.map +1 -0
  37. package/static/app/assets/{Capture-CAKouj52.js → Capture-BrAmsSEH.js} +2 -2
  38. package/static/app/assets/{Capture-CAKouj52.js.map → Capture-BrAmsSEH.js.map} +1 -1
  39. package/static/app/assets/Library-BOVzYfxI.js +2 -0
  40. package/static/app/assets/{Library-B_hW4AwR.js.map → Library-BOVzYfxI.js.map} +1 -1
  41. package/static/app/assets/System-D4WaN4kj.js +2 -0
  42. package/static/app/assets/System-D4WaN4kj.js.map +1 -0
  43. package/static/app/assets/index-BOB-W1FZ.js +17 -0
  44. package/static/app/assets/index-BOB-W1FZ.js.map +1 -0
  45. package/static/app/assets/{index-DKFpn5Kn.css → index-DvLsGy-z.css} +1 -1
  46. package/static/app/assets/primitives-C3_BfUb8.js +2 -0
  47. package/static/app/assets/primitives-C3_BfUb8.js.map +1 -0
  48. package/static/app/assets/textarea-CbvhHvzg.js +2 -0
  49. package/static/app/assets/{textarea-DR2Tyy_6.js.map → textarea-CbvhHvzg.js.map} +1 -1
  50. package/static/app/index.html +2 -2
  51. package/static/app/assets/Act-DhLaHnrT.js +0 -2
  52. package/static/app/assets/Brain-DS7NULyY.js +0 -322
  53. package/static/app/assets/Brain-DS7NULyY.js.map +0 -1
  54. package/static/app/assets/Library-B_hW4AwR.js +0 -2
  55. package/static/app/assets/System-9OAoXWPz.js +0 -2
  56. package/static/app/assets/System-9OAoXWPz.js.map +0 -1
  57. package/static/app/assets/index-C-7aHabu.js +0 -17
  58. package/static/app/assets/index-C-7aHabu.js.map +0 -1
  59. package/static/app/assets/primitives-DcO2H3yh.js +0 -2
  60. package/static/app/assets/primitives-DcO2H3yh.js.map +0 -1
  61. package/static/app/assets/textarea-DR2Tyy_6.js +0 -2
@@ -1,9 +1,9 @@
1
1
  import * as React from "react";
2
- import { Search } from "lucide-react";
2
+ import { Filter, RotateCcw, Search, X } from "lucide-react";
3
3
  import { t } from "@/i18n";
4
4
  import { useAppStore } from "@/store/appStore";
5
5
  import type { KnowledgeConcept, KnowledgeGraphModel } from "./types";
6
- import { clamp, layerStyle, layoutGraphNodes, polarPoint } from "./graphLayout";
6
+ import { clamp, computeGraphNeighbors, layerStyle, layoutGraphNodes, polarPoint } from "./graphLayout";
7
7
 
8
8
  export function BrainKnowledgeLayer({ concepts, depth }: { concepts: KnowledgeConcept[]; depth: number }) {
9
9
  const language = useAppStore((state) => state.language);
@@ -31,6 +31,31 @@ export function BrainKnowledgeLayer({ concepts, depth }: { concepts: KnowledgeCo
31
31
  );
32
32
  }
33
33
 
34
+ const DAY_MS = 24 * 60 * 60 * 1000;
35
+ // Time-exploration presets (days back). null == all time.
36
+ const TIME_WINDOWS: Array<{ days: number | null }> = [{ days: 7 }, { days: 30 }, { days: 90 }, { days: null }];
37
+ // A node added within this window of "now" is treated as recent for highlighting.
38
+ const RECENT_WINDOW_MS = 7 * DAY_MS;
39
+
40
+ // Highlight occurrences of `query` inside `label` by wrapping matches in <mark>.
41
+ function highlightMatch(label: string, query: string): React.ReactNode {
42
+ if (!query) return label;
43
+ const lower = label.toLowerCase();
44
+ const target = query.toLowerCase();
45
+ const parts: React.ReactNode[] = [];
46
+ let cursor = 0;
47
+ let matchIndex = lower.indexOf(target, cursor);
48
+ let key = 0;
49
+ while (matchIndex !== -1 && target.length > 0) {
50
+ if (matchIndex > cursor) parts.push(label.slice(cursor, matchIndex));
51
+ parts.push(<mark key={`m-${key++}`}>{label.slice(matchIndex, matchIndex + target.length)}</mark>);
52
+ cursor = matchIndex + target.length;
53
+ matchIndex = lower.indexOf(target, cursor);
54
+ }
55
+ if (cursor < label.length) parts.push(label.slice(cursor));
56
+ return parts.length ? parts : label;
57
+ }
58
+
34
59
  export function BrainGraphLayer({
35
60
  model,
36
61
  search,
@@ -46,13 +71,53 @@ export function BrainGraphLayer({
46
71
  }) {
47
72
  const language = useAppStore((state) => state.language);
48
73
  const query = search.trim().toLowerCase();
74
+
75
+ // Control state local to the graph view so existing lifted search/selection
76
+ // behavior (and its reset in surface()) is preserved untouched.
77
+ const [activeTypes, setActiveTypes] = React.useState<Set<string>>(() => new Set());
78
+ const [timeDays, setTimeDays] = React.useState<number | null>(null);
79
+ const [typeaheadOpen, setTypeaheadOpen] = React.useState(false);
80
+
81
+ // Distinct entity types available in the graph (for the type filter chips).
82
+ const availableTypes = React.useMemo(() => {
83
+ const seen = new Set<string>();
84
+ for (const node of model.nodes) seen.add(node.type);
85
+ return Array.from(seen).sort((a, b) => a.localeCompare(b));
86
+ }, [model.nodes]);
87
+
88
+ // Drop type selections that no longer exist after a graph refresh.
89
+ React.useEffect(() => {
90
+ setActiveTypes((prev) => {
91
+ if (prev.size === 0) return prev;
92
+ const next = new Set<string>();
93
+ for (const type of prev) if (availableTypes.includes(type)) next.add(type);
94
+ return next.size === prev.size ? prev : next;
95
+ });
96
+ }, [availableTypes]);
97
+
98
+ const hasTimestamps = React.useMemo(() => model.nodes.some((node) => typeof node.createdAt === "number"), [model.nodes]);
99
+ const timeCutoff = timeDays === null ? null : Date.now() - timeDays * DAY_MS;
100
+
101
+ const filtersActive = activeTypes.size > 0 || timeDays !== null;
102
+
49
103
  const visibleNodes = React.useMemo(() => {
50
104
  const filtered = model.nodes.filter((node) => {
105
+ if (activeTypes.size > 0 && !activeTypes.has(node.type)) return false;
106
+ if (timeCutoff !== null && typeof node.createdAt === "number" && node.createdAt < timeCutoff) return false;
51
107
  if (!query) return true;
52
108
  return `${node.label} ${node.type} ${node.summary}`.toLowerCase().includes(query);
53
109
  });
54
110
  return filtered.slice(0, 18);
111
+ }, [model.nodes, query, activeTypes, timeCutoff]);
112
+
113
+ // Typeahead suggestions: label-prefix/substring matches ranked by importance.
114
+ const suggestions = React.useMemo(() => {
115
+ if (!query) return [];
116
+ return model.nodes
117
+ .filter((node) => node.label.toLowerCase().includes(query) || node.type.toLowerCase().includes(query))
118
+ .slice(0, 6);
55
119
  }, [model.nodes, query]);
120
+
56
121
  const layout = React.useMemo(() => layoutGraphNodes(visibleNodes, 38, 24), [visibleNodes]);
57
122
  const positionById = React.useMemo(() => new Map(layout.map((item) => [item.node.id, item])), [layout]);
58
123
  const visibleEdges = React.useMemo(
@@ -61,6 +126,24 @@ export function BrainGraphLayer({
61
126
  );
62
127
  const selected = visibleNodes.find((node) => node.id === selectedId) || visibleNodes[0] || null;
63
128
 
129
+ // 1-hop neighbor set for the focused node, drives focus/dim + edge highlight.
130
+ const neighborIds = React.useMemo(
131
+ () => (selectedId ? computeGraphNeighbors(selectedId, model.edges) : new Set<string>()),
132
+ [selectedId, model.edges],
133
+ );
134
+ const focusActive = Boolean(selectedId) && neighborIds.size > 0;
135
+
136
+ const matchedIds = React.useMemo(() => {
137
+ if (!query) return new Set<string>();
138
+ return new Set(visibleNodes.filter((node) => node.label.toLowerCase().includes(query)).map((node) => node.id));
139
+ }, [visibleNodes, query]);
140
+
141
+ function focusState(nodeId: string): "true" | "false" | undefined {
142
+ if (!focusActive) return undefined;
143
+ if (nodeId === selectedId || neighborIds.has(nodeId)) return "true";
144
+ return "false";
145
+ }
146
+
64
147
  return (
65
148
  <section className="mind-core-graph" data-testid="emergent-knowledge-graph" aria-label={t(language, "brain.aria.graph")}>
66
149
  <div className="brain-graph-head">
@@ -68,24 +151,159 @@ export function BrainGraphLayer({
68
151
  <span>{t(language, "brain.level")} 5</span>
69
152
  <strong>{t(language, "brain.depth.5")}</strong>
70
153
  </div>
71
- <label className="brain-graph-search">
72
- <Search className="h-3.5 w-3.5" />
73
- <input
74
- value={search}
75
- onChange={(event) => onSearch(event.target.value)}
76
- placeholder={t(language, "brain.graph.search")}
77
- aria-label={t(language, "brain.graph.searchAria")}
78
- />
79
- </label>
154
+ <div className="brain-graph-search-wrap">
155
+ <label className="brain-graph-search">
156
+ <Search className="h-3.5 w-3.5" />
157
+ <input
158
+ value={search}
159
+ onChange={(event) => {
160
+ onSearch(event.target.value);
161
+ setTypeaheadOpen(true);
162
+ }}
163
+ onFocus={() => setTypeaheadOpen(true)}
164
+ onBlur={() => window.setTimeout(() => setTypeaheadOpen(false), 120)}
165
+ placeholder={t(language, "brain.graph.search")}
166
+ aria-label={t(language, "brain.graph.searchAria")}
167
+ role="combobox"
168
+ aria-expanded={typeaheadOpen && Boolean(query)}
169
+ aria-autocomplete="list"
170
+ aria-controls="brain-graph-typeahead"
171
+ />
172
+ {search ? (
173
+ <button
174
+ type="button"
175
+ className="brain-graph-search-clear"
176
+ onClick={() => onSearch("")}
177
+ aria-label={t(language, "brain.graph.focus.clear")}
178
+ >
179
+ <X className="h-3 w-3" />
180
+ </button>
181
+ ) : null}
182
+ </label>
183
+ {typeaheadOpen && query ? (
184
+ <ul
185
+ id="brain-graph-typeahead"
186
+ className="brain-graph-typeahead"
187
+ role="listbox"
188
+ aria-label={t(language, "brain.graph.search.typeaheadAria")}
189
+ >
190
+ {suggestions.length ? (
191
+ suggestions.map((node) => (
192
+ <li key={node.id} role="option" aria-selected={node.id === selectedId}>
193
+ <button
194
+ type="button"
195
+ onMouseDown={(event) => {
196
+ event.preventDefault();
197
+ onSearch(node.label);
198
+ onSelect(node.id);
199
+ setTypeaheadOpen(false);
200
+ }}
201
+ >
202
+ <span>{node.type}</span>
203
+ <strong>{highlightMatch(node.label, query)}</strong>
204
+ </button>
205
+ </li>
206
+ ))
207
+ ) : (
208
+ <li className="is-empty" aria-disabled>
209
+ {t(language, "brain.graph.search.noMatches")}
210
+ </li>
211
+ )}
212
+ </ul>
213
+ ) : null}
214
+ </div>
215
+ </div>
216
+
217
+ <div className="brain-graph-control-panel" role="group" aria-label={t(language, "brain.graph.control.panel")}>
218
+ <div className="brain-graph-filter-row">
219
+ <span className="brain-graph-control-label">
220
+ <Filter className="h-3 w-3" aria-hidden />
221
+ {t(language, "brain.graph.control.types")}
222
+ </span>
223
+ <div className="brain-graph-type-chips" role="group" aria-label={t(language, "brain.graph.control.types")}>
224
+ <button
225
+ type="button"
226
+ className={`brain-graph-chip ${activeTypes.size === 0 ? "is-active" : ""}`}
227
+ aria-pressed={activeTypes.size === 0}
228
+ onClick={() => setActiveTypes(new Set())}
229
+ >
230
+ {t(language, "brain.graph.control.allTypes")}
231
+ </button>
232
+ {availableTypes.map((type) => {
233
+ const active = activeTypes.has(type);
234
+ return (
235
+ <button
236
+ key={type}
237
+ type="button"
238
+ className={`brain-graph-chip ${active ? "is-active" : ""}`}
239
+ aria-pressed={active}
240
+ aria-label={t(language, "brain.graph.control.toggleType", { type })}
241
+ onClick={() =>
242
+ setActiveTypes((prev) => {
243
+ const next = new Set(prev);
244
+ if (next.has(type)) next.delete(type);
245
+ else next.add(type);
246
+ return next;
247
+ })
248
+ }
249
+ >
250
+ {type}
251
+ </button>
252
+ );
253
+ })}
254
+ </div>
255
+ </div>
256
+
257
+ <div className="brain-graph-filter-row">
258
+ <span className="brain-graph-control-label">{t(language, "brain.graph.control.time")}</span>
259
+ <div className="brain-graph-time-chips" role="group" aria-label={t(language, "brain.graph.control.dateRange")}>
260
+ {TIME_WINDOWS.map((window) => {
261
+ const active = timeDays === window.days;
262
+ const label =
263
+ window.days === null
264
+ ? t(language, "brain.graph.control.allTime")
265
+ : t(language, "brain.graph.control.recentWindow", { days: window.days });
266
+ return (
267
+ <button
268
+ key={window.days ?? "all"}
269
+ type="button"
270
+ className={`brain-graph-chip ${active ? "is-active" : ""}`}
271
+ aria-pressed={active}
272
+ disabled={window.days !== null && !hasTimestamps}
273
+ onClick={() => setTimeDays(window.days)}
274
+ >
275
+ {label}
276
+ </button>
277
+ );
278
+ })}
279
+ </div>
280
+ {filtersActive ? (
281
+ <button
282
+ type="button"
283
+ className="brain-graph-reset"
284
+ onClick={() => {
285
+ setActiveTypes(new Set());
286
+ setTimeDays(null);
287
+ }}
288
+ >
289
+ <RotateCcw className="h-3 w-3" aria-hidden />
290
+ {t(language, "brain.graph.control.resetFilters")}
291
+ </button>
292
+ ) : null}
293
+ </div>
80
294
  </div>
81
295
 
82
296
  {visibleNodes.length ? (
83
- <div className="brain-graph-canvas">
297
+ <div className="brain-graph-canvas" data-focus-active={focusActive ? "true" : "false"}>
84
298
  <svg className="brain-graph-edges" viewBox="0 0 100 100" aria-hidden>
85
299
  {visibleEdges.map((edge, index) => {
86
300
  const source = positionById.get(edge.source);
87
301
  const target = positionById.get(edge.target);
88
302
  if (!source || !target) return null;
303
+ const touchesFocus =
304
+ focusActive && (edge.source === selectedId || edge.target === selectedId);
305
+ const touchesMatch = matchedIds.has(edge.source) || matchedIds.has(edge.target);
306
+ const highlight = touchesFocus || (query ? touchesMatch : false);
89
307
  return (
90
308
  <line
91
309
  key={`${edge.id}-${index}`}
@@ -93,23 +311,32 @@ export function BrainGraphLayer({
93
311
  y1={source.y}
94
312
  x2={target.x}
95
313
  y2={target.y}
314
+ data-highlight={
315
+ focusActive || query ? (highlight ? "true" : "false") : undefined
316
+ }
96
317
  style={{ "--weight": String(clamp(edge.weight, 0.4, 2.8)) } as React.CSSProperties}
97
318
  />
98
319
  );
99
320
  })}
100
321
  </svg>
101
- {layout.map(({ node, x, y }, index) => (
102
- <button
103
- key={node.id}
104
- type="button"
105
- className={`graph-node ${selected?.id === node.id ? "is-selected" : ""}`}
106
- style={layerStyle({ "--x": `${x}%`, "--y": `${y}%`, "--delay": `${index * 35}ms` })}
107
- onClick={() => onSelect(node.id)}
108
- >
109
- <span>{node.type}</span>
110
- {node.label}
111
- </button>
112
- ))}
322
+ {layout.map(({ node, x, y }, index) => {
323
+ const isRecent =
324
+ typeof node.createdAt === "number" && Date.now() - node.createdAt <= RECENT_WINDOW_MS;
325
+ return (
326
+ <button
327
+ key={node.id}
328
+ type="button"
329
+ className={`graph-node ${selected?.id === node.id ? "is-selected" : ""} ${matchedIds.has(node.id) ? "is-match" : ""}`}
330
+ data-focus={focusState(node.id)}
331
+ data-recent={isRecent ? "true" : undefined}
332
+ style={layerStyle({ "--x": `${x}%`, "--y": `${y}%`, "--delay": `${index * 35}ms` })}
333
+ onClick={() => onSelect(selectedId === node.id ? null : node.id)}
334
+ >
335
+ <span>{node.type}</span>
336
+ {highlightMatch(node.label, query)}
337
+ </button>
338
+ );
339
+ })}
113
340
  </div>
114
341
  ) : (
115
342
  <div className="brain-graph-empty">{t(language, "brain.graph.empty")}</div>
@@ -121,7 +348,13 @@ export function BrainGraphLayer({
121
348
  <span>{selected.type}</span>
122
349
  <strong>{selected.label}</strong>
123
350
  <p>{selected.summary || t(language, "brain.graph.summaryFallback")}</p>
124
- <p>{t(language, "brain.graph.focused")}</p>
351
+ {focusActive ? (
352
+ <p className="brain-graph-focus-meta" role="status">
353
+ {t(language, "brain.graph.focus.neighbors", { count: neighborIds.size })}
354
+ </p>
355
+ ) : (
356
+ <p>{t(language, "brain.graph.focused")}</p>
357
+ )}
125
358
  </>
126
359
  ) : (
127
360
  <p>{t(language, "brain.graph.emptyFocus")}</p>
@@ -7,7 +7,19 @@ import { t } from "@/i18n";
7
7
  import { BrainConversation } from "./BrainConversation";
8
8
  import { buildBrainProof, buildBrainReadiness, buildMemoryFragments, currentModelName, parseKnowledgeGraph } from "./brainData";
9
9
  import { DepthEmergence } from "./DepthEmergence";
10
- import { DEPTHS, type BrainDepth, type BrainProof, type MemoryFragment, type Message, type MessageProof } from "./types";
10
+ import {
11
+ DEPTHS,
12
+ INGESTION_STAGE_ORDER,
13
+ type BrainDepth,
14
+ type BrainProof,
15
+ type EmergenceEvent,
16
+ type IngestionPipelineStage,
17
+ type IngestionSourceType,
18
+ type IngestionState,
19
+ type MemoryFragment,
20
+ type Message,
21
+ type MessageProof,
22
+ } from "./types";
11
23
 
12
24
  export function BrainHome({
13
25
  brainState,
@@ -31,8 +43,27 @@ export function BrainHome({
31
43
  const [memoryFeedback, setMemoryFeedback] = React.useState<string | null>(null);
32
44
  const [uploadingDocument, setUploadingDocument] = React.useState(false);
33
45
  const [lastRecallQuery, setLastRecallQuery] = React.useState("");
46
+ const [ingestionStates, setIngestionStates] = React.useState<Record<IngestionSourceType, IngestionState | null>>({
47
+ file: null,
48
+ folder: null,
49
+ note: null,
50
+ web: null,
51
+ });
52
+ const [emergenceEvents, setEmergenceEvents] = React.useState<EmergenceEvent[]>([]);
34
53
  const streamRef = React.useRef<HTMLDivElement>(null);
35
54
  const recallTimerRef = React.useRef<number | null>(null);
55
+ const stageTimersRef = React.useRef<Record<IngestionSourceType, number[]>>({
56
+ file: [],
57
+ folder: [],
58
+ note: [],
59
+ web: [],
60
+ });
61
+ const pendingBaselineRef = React.useRef<
62
+ Partial<Record<IngestionSourceType, { memories: number; entities: number; label: string }>>
63
+ >({});
64
+ // Source types whose request has resolved and are awaiting count settle to record emergence.
65
+ const awaitingEmergenceRef = React.useRef<Set<IngestionSourceType>>(new Set());
66
+ const settleTimerRef = React.useRef<number | null>(null);
36
67
 
37
68
  const memoriesQ = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
38
69
  const historyQ = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
@@ -89,9 +120,156 @@ export function BrainHome({
89
120
  React.useEffect(() => {
90
121
  return () => {
91
122
  if (recallTimerRef.current !== null) window.clearTimeout(recallTimerRef.current);
123
+ for (const timers of Object.values(stageTimersRef.current)) {
124
+ for (const timer of timers) window.clearTimeout(timer);
125
+ }
126
+ };
127
+ }, []);
128
+
129
+ const clearStageTimers = React.useCallback((sourceType: IngestionSourceType) => {
130
+ for (const timer of stageTimersRef.current[sourceType]) window.clearTimeout(timer);
131
+ stageTimersRef.current[sourceType] = [];
132
+ }, []);
133
+
134
+ const setStage = React.useCallback((sourceType: IngestionSourceType, stage: IngestionPipelineStage) => {
135
+ setIngestionStates((prev) => {
136
+ const current = prev[sourceType];
137
+ if (!current) return prev;
138
+ return {
139
+ ...prev,
140
+ [sourceType]: {
141
+ ...current,
142
+ stage,
143
+ completedAt: stage === "complete" || stage === "error" ? Date.now() : current.completedAt,
144
+ },
145
+ };
146
+ });
147
+ }, []);
148
+
149
+ const beginIngestion = React.useCallback(
150
+ (sourceType: IngestionSourceType, label: string) => {
151
+ clearStageTimers(sourceType);
152
+ pendingBaselineRef.current[sourceType] = {
153
+ memories: memoryFragments.length,
154
+ entities: knowledgeConcepts.length,
155
+ label,
156
+ };
157
+ setIngestionStates((prev) => ({
158
+ ...prev,
159
+ [sourceType]: {
160
+ sourceType,
161
+ label,
162
+ stage: "preparing",
163
+ startedAt: Date.now(),
164
+ completedAt: null,
165
+ newMemories: 0,
166
+ newEntities: 0,
167
+ },
168
+ }));
169
+ // Progressive disclosure of the in-flight pipeline while the request runs.
170
+ const interim: IngestionPipelineStage[] = ["parsing", "embedding", "indexing"];
171
+ interim.forEach((stage, index) => {
172
+ const timer = window.setTimeout(() => setStage(sourceType, stage), 420 * (index + 1));
173
+ stageTimersRef.current[sourceType].push(timer);
174
+ });
175
+ },
176
+ [clearStageTimers, knowledgeConcepts.length, memoryFragments.length, setStage],
177
+ );
178
+
179
+ const resolveEmergence = React.useCallback(
180
+ (sourceType: IngestionSourceType, memoryCount: number, entityCount: number) => {
181
+ clearStageTimers(sourceType);
182
+ const baseline = pendingBaselineRef.current[sourceType];
183
+ const label = baseline?.label ?? "";
184
+ // Snapshot deltas after invalidation lands; cap at >=0 to avoid noise.
185
+ const newMemories = Math.max(0, memoryCount - (baseline?.memories ?? memoryCount));
186
+ const newEntities = Math.max(0, entityCount - (baseline?.entities ?? entityCount));
187
+ delete pendingBaselineRef.current[sourceType];
188
+ setIngestionStates((prev) => {
189
+ const current = prev[sourceType];
190
+ if (!current) return prev;
191
+ return {
192
+ ...prev,
193
+ [sourceType]: {
194
+ ...current,
195
+ stage: "complete",
196
+ completedAt: Date.now(),
197
+ newMemories,
198
+ newEntities,
199
+ },
200
+ };
201
+ });
202
+ setEmergenceEvents((events) =>
203
+ [
204
+ {
205
+ id: `${sourceType}-${Date.now()}`,
206
+ sourceType,
207
+ label,
208
+ newMemories,
209
+ newEntities,
210
+ at: Date.now(),
211
+ },
212
+ ...events,
213
+ ].slice(0, 10),
214
+ );
215
+ },
216
+ [clearStageTimers],
217
+ );
218
+
219
+ // Once a request resolves we wait for the refetched counts to settle, then record the
220
+ // real emergence delta. A fallback timer guarantees the panel never hangs on a stale count.
221
+ const markAwaitingEmergence = React.useCallback(
222
+ (sourceType: IngestionSourceType) => {
223
+ awaitingEmergenceRef.current.add(sourceType);
224
+ if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
225
+ settleTimerRef.current = window.setTimeout(() => {
226
+ for (const pending of Array.from(awaitingEmergenceRef.current)) {
227
+ resolveEmergence(pending, memoryFragments.length, knowledgeConcepts.length);
228
+ awaitingEmergenceRef.current.delete(pending);
229
+ }
230
+ }, 1600);
231
+ },
232
+ [knowledgeConcepts.length, memoryFragments.length, resolveEmergence],
233
+ );
234
+
235
+ // Flush awaiting ingestions as soon as the underlying counts change post-invalidation.
236
+ React.useEffect(() => {
237
+ if (awaitingEmergenceRef.current.size === 0) return;
238
+ for (const pending of Array.from(awaitingEmergenceRef.current)) {
239
+ const baseline = pendingBaselineRef.current[pending];
240
+ if (!baseline) {
241
+ awaitingEmergenceRef.current.delete(pending);
242
+ continue;
243
+ }
244
+ if (memoryFragments.length !== baseline.memories || knowledgeConcepts.length !== baseline.entities) {
245
+ resolveEmergence(pending, memoryFragments.length, knowledgeConcepts.length);
246
+ awaitingEmergenceRef.current.delete(pending);
247
+ }
248
+ }
249
+ }, [memoryFragments.length, knowledgeConcepts.length, resolveEmergence]);
250
+
251
+ React.useEffect(() => {
252
+ return () => {
253
+ if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
92
254
  };
93
255
  }, []);
94
256
 
257
+ const failIngestion = React.useCallback(
258
+ (sourceType: IngestionSourceType, reason: string) => {
259
+ clearStageTimers(sourceType);
260
+ delete pendingBaselineRef.current[sourceType];
261
+ setIngestionStates((prev) => {
262
+ const current = prev[sourceType];
263
+ if (!current) return prev;
264
+ return {
265
+ ...prev,
266
+ [sourceType]: { ...current, stage: "error", completedAt: Date.now(), error: reason },
267
+ };
268
+ });
269
+ },
270
+ [clearStageTimers],
271
+ );
272
+
95
273
  async function send() {
96
274
  const text = draft.trim();
97
275
  if (!text || streaming) return;
@@ -167,11 +345,13 @@ export function BrainHome({
167
345
  setUploadingDocument(true);
168
346
  setMemoryFeedback(t(language, "brain.upload.pending", { name: file.name }));
169
347
  onBrainChange("recalling", 0.86);
348
+ beginIngestion("file", file.name);
170
349
 
171
350
  try {
172
351
  const result = await latticeApi.uploadDocument(file);
173
352
  if (result.error) {
174
353
  setMemoryFeedback(t(language, "brain.upload.failed", { reason: result.error }));
354
+ failIngestion("file", result.error);
175
355
  return;
176
356
  }
177
357
 
@@ -181,6 +361,7 @@ export function BrainHome({
181
361
  void qc.invalidateQueries({ queryKey: ["memoryManager"] });
182
362
  void qc.invalidateQueries({ queryKey: ["graph"] });
183
363
  void qc.invalidateQueries({ queryKey: ["memoryBrainProof"] });
364
+ markAwaitingEmergence("file");
184
365
  } finally {
185
366
  setUploadingDocument(false);
186
367
  }
@@ -191,15 +372,18 @@ export function BrainHome({
191
372
  if (!target) return;
192
373
  setMemoryFeedback(t(language, "brain.ingest.folder.pending", { path: target }));
193
374
  onBrainChange("recalling", 0.84);
375
+ beginIngestion("folder", target);
194
376
  const result = await latticeApi.connectFolder(target);
195
377
  if (result.error || !result.ok) {
196
378
  setMemoryFeedback(t(language, "brain.ingest.folder.failed", { reason: result.error || "unavailable" }));
379
+ failIngestion("folder", result.error || "unavailable");
197
380
  return;
198
381
  }
199
382
  setMemoryFeedback(t(language, "brain.ingest.folder.saved", { path: target }));
200
383
  setLastRecallQuery(target);
201
384
  triggerBrainRecall();
202
385
  void refreshBrainProof(target);
386
+ markAwaitingEmergence("folder");
203
387
  }
204
388
 
205
389
  async function ingestNote(note: string) {
@@ -207,15 +391,18 @@ export function BrainHome({
207
391
  if (!content) return;
208
392
  setMemoryFeedback(t(language, "brain.ingest.note.pending"));
209
393
  onBrainChange("recalling", 0.84);
394
+ beginIngestion("note", content.slice(0, 80));
210
395
  const result = await latticeApi.ingestNote(content, content.slice(0, 80));
211
396
  if (result.error || !result.ok) {
212
397
  setMemoryFeedback(t(language, "brain.ingest.note.failed", { reason: result.error || "unavailable" }));
398
+ failIngestion("note", result.error || "unavailable");
213
399
  return;
214
400
  }
215
401
  setMemoryFeedback(t(language, "brain.ingest.note.saved"));
216
402
  setLastRecallQuery(content.slice(0, 120));
217
403
  triggerBrainRecall();
218
404
  void refreshBrainProof(content.slice(0, 120));
405
+ markAwaitingEmergence("note");
219
406
  }
220
407
 
221
408
  async function ingestWeb(url: string) {
@@ -223,15 +410,18 @@ export function BrainHome({
223
410
  if (!target) return;
224
411
  setMemoryFeedback(t(language, "brain.ingest.web.pending", { url: target }));
225
412
  onBrainChange("recalling", 0.84);
413
+ beginIngestion("web", target);
226
414
  const result = await latticeApi.browserReadUrl(target);
227
415
  if (result.error || !result.ok) {
228
416
  setMemoryFeedback(t(language, "brain.ingest.web.failed", { reason: result.error || "unavailable" }));
417
+ failIngestion("web", result.error || "unavailable");
229
418
  return;
230
419
  }
231
420
  setMemoryFeedback(t(language, "brain.ingest.web.saved", { url: target }));
232
421
  setLastRecallQuery(target);
233
422
  triggerBrainRecall();
234
423
  void refreshBrainProof(target);
424
+ markAwaitingEmergence("web");
235
425
  }
236
426
 
237
427
  async function refreshBrainProof(query = lastRecallQuery) {
@@ -361,6 +551,8 @@ export function BrainHome({
361
551
  messages={messages}
362
552
  starterPrompts={starterPrompts}
363
553
  memoryFeedback={memoryFeedback}
554
+ ingestionStates={ingestionStates}
555
+ emergenceEvents={emergenceEvents}
364
556
  draft={draft}
365
557
  streaming={streaming}
366
558
  imageData={imageData}
@@ -33,7 +33,9 @@ export function parseKnowledgeGraph(data: unknown): KnowledgeGraphModel {
33
33
  const label = textValue(node, ["title", "label", "name"], id.replace(/^[^:]+:/, ""));
34
34
  const summary = textValue(node, ["summary", "description", "snippet"]) || textValue(metadata, ["summary", "description", "relative_path", "filename"]);
35
35
  const importance = clamp(numberValue(node, ["importance_norm", "importance", "score"]) || 0.5, 0.08, 1);
36
- return [{ id, label, type, summary, importance }];
36
+ const createdAt = timestampValue(node, ["created_at", "createdAt", "added_at", "addedAt", "timestamp", "updated_at", "updatedAt"])
37
+ ?? timestampValue(metadata, ["created_at", "createdAt", "added_at", "addedAt", "timestamp", "updated_at", "updatedAt"]);
38
+ return [{ id, label, type, summary, importance, ...(createdAt !== undefined ? { createdAt } : {}) }];
37
39
  }).sort((left, right) => right.importance - left.importance);
38
40
  const ids = new Set(nodes.map((node) => node.id));
39
41
  const edges = rawEdges.flatMap((edge, index): RelationshipThread[] => {
@@ -205,6 +207,28 @@ function titleValue(record: ApiRecord, keys: string[], fallback = "") {
205
207
  .replace(/\b\w/g, (character) => character.toUpperCase());
206
208
  }
207
209
 
210
+ // Parse a created/added timestamp into unix epoch milliseconds. Accepts ISO 8601
211
+ // strings, unix seconds, or unix milliseconds. Returns undefined when absent or
212
+ // unparseable so the time-exploration UI can fall back gracefully.
213
+ function timestampValue(record: ApiRecord, keys: string[]): number | undefined {
214
+ for (const key of keys) {
215
+ const value = record[key];
216
+ if (typeof value === "number" && Number.isFinite(value)) {
217
+ // Heuristic: values below ~1e12 are seconds, otherwise milliseconds.
218
+ return value < 1e12 ? value * 1000 : value;
219
+ }
220
+ if (typeof value === "string" && value.trim()) {
221
+ const numeric = Number(value);
222
+ if (Number.isFinite(numeric) && /^\d+$/.test(value.trim())) {
223
+ return numeric < 1e12 ? numeric * 1000 : numeric;
224
+ }
225
+ const parsed = Date.parse(value);
226
+ if (Number.isFinite(parsed)) return parsed;
227
+ }
228
+ }
229
+ return undefined;
230
+ }
231
+
208
232
  function numberValue(record: ApiRecord, keys: string[]) {
209
233
  for (const key of keys) {
210
234
  const value = Number(record[key]);