graph-engine-memory 0.6.1__py3-none-any.whl

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 (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,578 @@
1
+ // GraphEngine: explore ideas, review connections, and undo saved decisions.
2
+ (() => {
3
+ "use strict";
4
+ const $ = selector => document.querySelector(selector);
5
+ const colors = { "similar": "#6ed5a0", "contradicts": "#ff9393", "extends": "#83b9ff", "same_as": "#bc8cff", "supersedes": "#f0883e", "continues": "#58a6ff" };
6
+ const labels = { "similar": "Similar", "contradicts": "Contradiction", "extends": "Extension", "same_as": "Same idea", "supersedes": "Supersedes", "continues": "Continues" };
7
+ const edgeLabel = edge => edge.kind === "fact" ? edge.predicate || "Fact" : labels[edge.kind] || edge.kind;
8
+ const esc = value => String(value).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
9
+ // Audit #50: slice by code points — UTF-16 indexing split surrogate pairs (emoji).
10
+ const short = (text, length = 36) => {
11
+ const chars = Array.from(text);
12
+ return chars.length > length ? `${chars.slice(0, length - 1).join("")}…` : text;
13
+ };
14
+ const idOf = value => typeof value === "object" ? value.id : value;
15
+ const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
16
+ let nodes = [], edges = [], links = [], pending = [];
17
+ let selectedEdge = null, selectedNode = null, detailId = null, undoId = null;
18
+ let loaded = false, saving = false, resolving = false, refreshVersion = 0;
19
+ let noticeTimer, reconnectTimer, socket, width = 1, height = 1;
20
+ let initialFit = true, graph3D = null, use3D = false;
21
+ const sprites = new Map();
22
+ const nodeById = id => nodes.find(node => node.id === id);
23
+ const textOf = id => nodeById(id)?.text || "Idea unavailable";
24
+
25
+ function notify(message, error = false) {
26
+ clearTimeout(noticeTimer);
27
+ $("#notice").textContent = message;
28
+ $("#notice").classList.toggle("error", error);
29
+ noticeTimer = setTimeout(() => { $("#notice").textContent = ""; }, error ? 8000 : 4000);
30
+ }
31
+
32
+ // Keep the rest of the page understandable if the graph library cannot load.
33
+ if (!window.d3) {
34
+ $("#graph-empty h2").textContent = "The graph could not be loaded";
35
+ $("#graph-empty p").textContent = "Check your connection and reload the page.";
36
+ $("#stats").textContent = "Graph unavailable";
37
+ $("#connection").textContent = "Load failed";
38
+ $("#empty-action").hidden = false;
39
+ $("#empty-action").textContent = "Reload";
40
+ $("#empty-action").onclick = () => location.reload();
41
+ $("#ingest-button").disabled = true;
42
+ $("#bar").addEventListener("submit", event => event.preventDefault());
43
+ return;
44
+ }
45
+
46
+ const svg = d3.select("#graph");
47
+ const viewport = svg.append("g");
48
+ const linkLayer = viewport.append("g").attr("aria-hidden", "true");
49
+ const nodeLayer = viewport.append("g");
50
+ const zoom = d3.zoom().scaleExtent([0.12, 4]).on("zoom", event => {
51
+ viewport.attr("transform", event.transform);
52
+ $("#tooltip").hidden = true;
53
+ });
54
+ svg.call(zoom).on("dblclick.zoom", null);
55
+ svg.on("click", event => { if (event.target === svg.node()) clearSelection(); });
56
+ const simulation = d3.forceSimulation()
57
+ .force("link", d3.forceLink().id(node => node.id).distance(190))
58
+ .force("charge", d3.forceManyBody().strength(-550))
59
+ .force("collide", d3.forceCollide(65))
60
+ .force("center", d3.forceCenter())
61
+ .on("tick", tick)
62
+ .on("end", () => { if (initialFit && nodes.length) { fitNodes(nodes); initialFit = false; } });
63
+
64
+ function tick() {
65
+ linkLayer.selectAll("line")
66
+ .attr("x1", edge => edge.source.x).attr("y1", edge => edge.source.y)
67
+ .attr("x2", edge => edge.target.x).attr("y2", edge => edge.target.y);
68
+ nodeLayer.selectAll("g.node").attr("transform", node => `translate(${node.x},${node.y})`);
69
+ }
70
+
71
+ function drawGraph() {
72
+ linkLayer.selectAll("line").data(links, edge => edge.id).join("line")
73
+ .attr("stroke", edge => colors[edge.kind] || "#a0afc1")
74
+ .attr("stroke-dasharray", edge => edge.pending ? "5 6" : null);
75
+ const groups = nodeLayer.selectAll("g.node").data(nodes, node => node.id).join(enter => {
76
+ const group = enter.append("g").attr("class", "node").attr("tabindex", 0).attr("role", "button")
77
+ .on("click", (event, node) => { event.stopPropagation(); showDetail(node.id); })
78
+ .on("keydown", (event, node) => {
79
+ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); showDetail(node.id); }
80
+ })
81
+ .on("pointerenter", (event, node) => {
82
+ if (event.pointerType === "touch") return;
83
+ const tooltip = $("#tooltip");
84
+ tooltip.textContent = short(node.text, 240);
85
+ tooltip.hidden = false;
86
+ const rect = $("#canvas").getBoundingClientRect();
87
+ tooltip.style.left = `${Math.max(8, Math.min(event.clientX - rect.left + 16, rect.width - tooltip.offsetWidth - 8))}px`;
88
+ tooltip.style.top = `${Math.max(8, Math.min(event.clientY - rect.top + 16, rect.height - tooltip.offsetHeight - 8))}px`;
89
+ })
90
+ .on("pointerleave", () => { $("#tooltip").hidden = true; })
91
+ .call(d3.drag()
92
+ .on("start", (event, node) => { initialFit = false; if (!event.active) simulation.alphaTarget(.2).restart(); node.fx = node.x; node.fy = node.y; })
93
+ .on("drag", (event, node) => { node.fx = event.x; node.fy = event.y; })
94
+ .on("end", (event, node) => { if (!event.active) simulation.alphaTarget(0); node.fx = null; node.fy = null; }));
95
+ group.append("circle").attr("class", "halo").attr("r", 20);
96
+ group.append("circle").attr("class", "dot").attr("r", 9);
97
+ group.append("text").attr("text-anchor", "middle").attr("y", -29);
98
+ group.append("title");
99
+ return group;
100
+ });
101
+ groups.attr("aria-label", node => `Open idea: ${node.text}`);
102
+ groups.select("text").text(node => short(node.text.replace(/\s+/g, " ")));
103
+ groups.select("title").text(node => node.text);
104
+ if (graph3D) update3D();
105
+ highlight();
106
+ tick();
107
+ }
108
+
109
+ function highlight() {
110
+ const selected = pending.find(edge => edge.id === selectedEdge) || edges.find(edge => edge.id === selectedEdge);
111
+ const ids = new Set(selected ? [selected.source, selected.target] : selectedNode ? [selectedNode] : []);
112
+ const searching = $("#search").value.trim().toLowerCase();
113
+ if (graph3D) {
114
+ graph3D.nodeColor(node => (ids.size ? !ids.has(node.id) : searching && !node.text.toLowerCase().includes(searching)) ? "#303b49" : ids.has(node.id) ? "#83b9ff" : "#eaf0f7");
115
+ for (const [id, sprite] of sprites) {
116
+ sprite.material.opacity = (ids.size ? !ids.has(id) : searching && !sprite.text.toLowerCase().includes(searching)) ? .2 : 1;
117
+ }
118
+ graph3D.linkColor(edge => ids.size && !(selected ? edge.id === selectedEdge : ids.has(idOf(edge.source)) || ids.has(idOf(edge.target))) ? "#202a36" : colors[edge.kind] || "#a0afc1");
119
+ graph3D.linkWidth(edge => edge.id === selectedEdge ? 2.5 : edge.pending ? .4 : 1);
120
+ }
121
+ nodeLayer.selectAll(".node")
122
+ .classed("selected", node => ids.has(node.id))
123
+ .classed("dimmed", node => ids.size ? !ids.has(node.id) : searching ? !node.text.toLowerCase().includes(searching) : false);
124
+ linkLayer.selectAll("line")
125
+ .attr("stroke-width", edge => edge.id === selectedEdge ? 3 : 1.5)
126
+ .attr("opacity", edge => ids.size ? (selected ? edge.id === selectedEdge : ids.has(idOf(edge.source)) || ids.has(idOf(edge.target))) ? 1 : .08 : edge.pending ? .5 : .75);
127
+ }
128
+
129
+ function fitNodes(targets) {
130
+ if (!targets.length || width < 2 || height < 2) return;
131
+ if (use3D && graph3D) {
132
+ const ids = new Set(targets.map(node => node.id));
133
+ graph3D.zoomToFit(motion.matches ? 0 : 280, 60, node => ids.has(node.id));
134
+ return;
135
+ }
136
+ const x0 = d3.min(targets, node => node.x) - 130, x1 = d3.max(targets, node => node.x) + 130;
137
+ const y0 = d3.min(targets, node => node.y) - 65, y1 = d3.max(targets, node => node.y) + 65;
138
+ const scale = Math.max(.12, Math.min(1.5, width / (x1 - x0), height / (y1 - y0)) * .85);
139
+ const transform = d3.zoomIdentity.translate(width / 2, height / 2).scale(scale).translate(-(x0 + x1) / 2, -(y0 + y1) / 2);
140
+ svg.interrupt().transition().duration(motion.matches ? 0 : 280).call(zoom.transform, transform);
141
+ }
142
+
143
+ new ResizeObserver(entries => {
144
+ const rect = entries[0].contentRect;
145
+ if (!rect.width || !rect.height) return;
146
+ width = rect.width; height = rect.height;
147
+ if (graph3D) graph3D.width(width).height(height);
148
+ svg.attr("viewBox", `0 0 ${width} ${height}`);
149
+ simulation.force("center").x(width / 2).y(height / 2);
150
+ simulation.alpha(.2).restart();
151
+ // Audit #45: auto-fit only until the user pans/zooms themselves — otherwise
152
+ // every resize oscillation (mobile URL bar) clobbers the user's view.
153
+ if (loaded && initialFit) {
154
+ const edge = pending.find(item => item.id === selectedEdge) || edges.find(item => item.id === selectedEdge);
155
+ const targets = edge ? nodes.filter(node => node.id === edge.source || node.id === edge.target) : selectedNode ? nodes.filter(node => node.id === selectedNode) : nodes;
156
+ fitNodes(targets);
157
+ }
158
+ }).observe($("#canvas"));
159
+
160
+ function setView(view) {
161
+ document.body.dataset.view = view;
162
+ document.querySelectorAll("#mobile-tabs button").forEach(button => button.setAttribute("aria-pressed", String(button.dataset.view === view)));
163
+ }
164
+ document.querySelectorAll("#mobile-tabs button").forEach(button => button.addEventListener("click", () => setView(button.dataset.view)));
165
+ $("#zoom-in").onclick = () => { initialFit = false; if (use3D) zoom3D(1 / 1.3); else svg.call(zoom.scaleBy, 1.3); };
166
+ $("#zoom-out").onclick = () => { initialFit = false; if (use3D) zoom3D(1.3); else svg.call(zoom.scaleBy, 1 / 1.3); };
167
+ $("#fit").onclick = () => { clearSelection(); fitNodes(nodes); };
168
+ svg.on("wheel.intent pointerdown.intent", () => { initialFit = false; });
169
+
170
+ // Keep the upstream WebGL view, with independent simulation objects so the
171
+ // 2D and 3D force engines never overwrite one another's positions.
172
+ function label3D(node) {
173
+ if (sprites.has(node.id)) return sprites.get(node.id).object;
174
+ const canvas = document.createElement("canvas");
175
+ canvas.width = 640; canvas.height = 96;
176
+ const context = canvas.getContext("2d");
177
+ context.font = "32px system-ui, sans-serif";
178
+ context.textAlign = "center"; context.textBaseline = "middle";
179
+ context.fillStyle = "#eaf0f7";
180
+ context.fillText(short(node.text, 30), 320, 48, 620);
181
+ const texture = new THREE.CanvasTexture(canvas);
182
+ const material = new THREE.SpriteMaterial({ map:texture, transparent:true, depthWrite:false });
183
+ const sprite = new THREE.Sprite(material);
184
+ sprite.scale.set(65, 10, 1); sprite.position.y = 12;
185
+ const object = new THREE.Group(); object.add(sprite);
186
+ sprites.set(node.id, { object, texture, material, text:node.text });
187
+ return object;
188
+ }
189
+
190
+ function update3D() {
191
+ const existing = new Map(graph3D.graphData().nodes.map(node => [node.id, node]));
192
+ const degree = new Map();
193
+ edges.forEach(edge => [edge.source, edge.target].forEach(id => degree.set(id, (degree.get(id) || 0) + 1)));
194
+ for (const [id, sprite] of sprites) {
195
+ if (nodeById(id)?.text !== sprite.text) {
196
+ sprite.texture.dispose(); sprite.material.dispose(); sprites.delete(id);
197
+ }
198
+ }
199
+ const data = nodes.map(node => Object.assign(existing.get(node.id) || {}, { id:node.id, text:node.text, val:1 + Math.log2(1 + (degree.get(node.id) || 0)) }));
200
+ graph3D.graphData({ nodes:data, links:edges.map(edge => ({ ...edge })) });
201
+ graph3D.nodeThreeObject(label3D);
202
+ }
203
+
204
+ function zoom3D(factor) {
205
+ const camera = graph3D.camera().position;
206
+ const target = graph3D.controls().target;
207
+ graph3D.cameraPosition({
208
+ x:target.x + (camera.x - target.x) * factor,
209
+ y:target.y + (camera.y - target.y) * factor,
210
+ z:target.z + (camera.z - target.z) * factor,
211
+ }, target, motion.matches ? 0 : 200);
212
+ }
213
+
214
+ $("#graph-mode").onclick = () => {
215
+ try {
216
+ if (!graph3D) {
217
+ if (!window.ForceGraph3D || !window.THREE) throw new Error("3D unavailable");
218
+ graph3D = ForceGraph3D()($("#graph3d"))
219
+ .width(width).height(height).backgroundColor("#0d1117")
220
+ .nodeRelSize(5).nodeLabel(node => esc(node.text))
221
+ .nodeThreeObjectExtend(true).nodeThreeObject(label3D)
222
+ .linkOpacity(.65).linkLabel(edge => esc(`${edgeLabel(edge)}${edge.pending ? " · Suggestion" : ""}`))
223
+ .onNodeClick(node => showDetail(node.id)).onBackgroundClick(clearSelection)
224
+ .warmupTicks(80).cooldownTicks(100);
225
+ update3D();
226
+ }
227
+ use3D = !use3D;
228
+ $("#graph3d").hidden = !use3D; $("#graph").hidden = use3D;
229
+ $("#graph-mode").setAttribute("aria-pressed", String(use3D));
230
+ if (use3D) graph3D.resumeAnimation(); else graph3D.pauseAnimation();
231
+ highlight(); fitNodes(nodes);
232
+ } catch {
233
+ graph3D?.pauseAnimation(); graph3D = null; use3D = false;
234
+ $("#graph3d").hidden = true; $("#graph").hidden = false;
235
+ $("#graph-mode").setAttribute("aria-pressed", "false");
236
+ notify("3D is unavailable here. You can keep using the graph in 2D.");
237
+ }
238
+ };
239
+
240
+ function clearSelection() {
241
+ selectedEdge = null; selectedNode = null;
242
+ document.querySelectorAll(".card").forEach(card => card.classList.remove("active"));
243
+ highlight();
244
+ }
245
+
246
+ function selectEdge(id, reveal = false) {
247
+ selectedEdge = id; selectedNode = null; initialFit = false;
248
+ const edge = pending.find(item => item.id === id) || edges.find(item => item.id === id);
249
+ if (!edge) return;
250
+ document.querySelectorAll(".card").forEach(card => card.classList.toggle("active", card.dataset.id === id));
251
+ if (reveal) setView("graph");
252
+ highlight();
253
+ requestAnimationFrame(() => fitNodes(nodes.filter(node => node.id === edge.source || node.id === edge.target)));
254
+ }
255
+
256
+ async function request(url, options) {
257
+ const response = await fetch(url, options);
258
+ if (!response.ok) {
259
+ const error = new Error(response.status === 404 || response.status === 409 ? "This suggestion was already changed. Please pick it again." : "Saving failed. Please try again.");
260
+ error.status = response.status;
261
+ throw error;
262
+ }
263
+ return response.json();
264
+ }
265
+
266
+ // Audit #46: one action triggered two full /api/graph fetches (explicit +
267
+ // the actor's own WS broadcast). A 100ms trailing debounce collapses both into one.
268
+ let refreshTimer = null;
269
+ function scheduleRefresh(delay = 100) {
270
+ clearTimeout(refreshTimer);
271
+ refreshTimer = setTimeout(refresh, delay);
272
+ }
273
+
274
+ async function refresh() {
275
+ clearTimeout(refreshTimer);
276
+ const version = ++refreshVersion;
277
+ try {
278
+ const graph = await request("/api/graph");
279
+ if (version !== refreshVersion) return;
280
+ const existing = new Map(nodes.map(node => [node.id, node]));
281
+ nodes = graph.nodes.map(node => Object.assign(existing.get(node.id) || {}, node));
282
+ const ids = new Set(nodes.map(node => node.id));
283
+ const eligible = graph.edges.filter(edge => ids.has(edge.source) && ids.has(edge.target) && !edge.rejected);
284
+ edges = eligible.filter(edge => edge.current ?? !edge.valid_to);
285
+ links = edges.map(edge => ({ ...edge }));
286
+ // Historical and scheduled facts still need review, even off the live graph.
287
+ pending = eligible.filter(edge => edge.pending && !(edge.invalidated ?? Boolean(edge.valid_to)));
288
+ if (!pending.some(edge => edge.id === selectedEdge)) selectedEdge = null;
289
+ if (!ids.has(selectedNode)) selectedNode = null;
290
+ // Clear old links before replacing nodes, so deleted endpoints cannot leak into the simulation.
291
+ simulation.force("link").links([]);
292
+ simulation.nodes(nodes);
293
+ simulation.force("link").links(links);
294
+ loaded = true;
295
+ $("#stats").textContent = `${nodes.length} ${nodes.length === 1 ? "idea" : "ideas"} · ${edges.filter(edge => !edge.pending).length} connections`;
296
+ $("#pending-count").textContent = pending.length;
297
+ $("#mobile-count").textContent = pending.length ? `(${pending.length})` : "";
298
+ $("#graph-empty").hidden = nodes.length > 0;
299
+ $("#graph-empty h2").textContent = "Every connection starts with an idea";
300
+ $("#graph-empty p").textContent = "Capture your first thought. More ideas will surface connection suggestions.";
301
+ $("#empty-action").hidden = false;
302
+ $("#empty-action").textContent = "Add your first idea";
303
+ $("#empty-action").onclick = () => $("#text").focus();
304
+ renderCards(); drawGraph();
305
+ simulation.alpha(.6).restart();
306
+ if (initialFit && nodes.length) { simulation.tick(80); tick(); fitNodes(nodes); initialFit = false; }
307
+ if (detailId) renderDetail(detailId);
308
+ if (document.activeElement === $("#search")) renderSearch();
309
+ } catch (error) {
310
+ if (version !== refreshVersion) return;
311
+ if (!loaded) {
312
+ $("#stats").textContent = "Ideas not loaded";
313
+ $("#graph-empty h2").textContent = "Your ideas could not be loaded";
314
+ $("#graph-empty p").textContent = "Check the connection and try again.";
315
+ $("#empty-action").hidden = false;
316
+ $("#empty-action").textContent = "Try again";
317
+ $("#empty-action").onclick = refresh;
318
+ $("#cards").innerHTML = '<div class="empty-inbox"><p>Suggestions are available once the connection is restored.</p></div>';
319
+ // Audit #50: the badge kept saying "Connecting …" forever when the very
320
+ // first load failed (WS had not flipped it yet).
321
+ $("#connection").dataset.state = "offline";
322
+ $("#connection").textContent = "Load failed";
323
+ }
324
+ notify("The graph could not be refreshed. Please try again.", true);
325
+ } finally {
326
+ $("#cards").setAttribute("aria-busy", "false");
327
+ }
328
+ }
329
+
330
+ function renderCards() {
331
+ const cards = $("#cards");
332
+ const active = document.activeElement;
333
+ const focusedId = active?.closest(".card")?.dataset.id;
334
+ const focusedAction = active?.dataset.action;
335
+ if (!pending.length) {
336
+ cards.innerHTML = `<div class="empty-inbox"><h3>${nodes.length ? "All reviewed" : "Room for new connections"}</h3><p>${nodes.length ? "There are no open suggestions right now. Add more ideas to discover new connections." : "Once your ideas form connections, you can review them here."}</p></div>`;
337
+ return;
338
+ }
339
+ cards.innerHTML = pending.map(edge => `
340
+ <article class="card ${edge.id === selectedEdge ? "active" : ""}" data-id="${esc(edge.id)}">
341
+ <div class="card-heading"><span class="kind" data-kind="${esc(edge.kind)}">${esc(edgeLabel(edge))}</span><button class="select-pair quiet" data-action="select">Show in graph</button></div>
342
+ <button class="idea-preview" data-action="source" aria-label="Read first idea in full"><span class="excerpt">${esc(textOf(edge.source))}</span><span class="read">Read idea ↗</span></button>
343
+ <button class="idea-preview" data-action="target" aria-label="Read second idea in full"><span class="excerpt">${esc(textOf(edge.target))}</span><span class="read">Read idea ↗</span></button>
344
+ ${edge.kind === "fact" ? (edge.evidence || []).slice(0, 3).map(item => `<p class="fact-evidence">${esc(short(item.text, 240))}</p>`).join("") : ""}
345
+ <div class="actions"><button class="ok" data-action="accept" ${resolving ? "disabled" : ""}>&#10003; Accept</button><button class="quiet" data-action="reject" ${resolving ? "disabled" : ""}>Dismiss</button></div>
346
+ </article>`).join("");
347
+ if (focusedId && focusedAction) {
348
+ const card = [...cards.children].find(item => item.dataset.id === focusedId);
349
+ card?.querySelector(`[data-action="${focusedAction}"]`)?.focus({ preventScroll:true });
350
+ }
351
+ }
352
+
353
+ $("#cards").addEventListener("click", event => {
354
+ const card = event.target.closest(".card");
355
+ if (!card) return;
356
+ const edge = pending.find(item => item.id === card.dataset.id);
357
+ if (!edge) return;
358
+ const action = event.target.closest("button")?.dataset.action;
359
+ if (action === "accept" || action === "reject") resolveEdge(edge.id, action);
360
+ else if (action === "source" || action === "target") { selectEdge(edge.id); showDetail(edge[action]); }
361
+ else selectEdge(edge.id, action === "select");
362
+ });
363
+
364
+ function setResolving(value) {
365
+ resolving = value;
366
+ document.querySelectorAll('[data-action="accept"], [data-action="reject"], #undo').forEach(button => { button.disabled = value; });
367
+ }
368
+
369
+ async function resolveEdge(id, action) {
370
+ if (resolving) return;
371
+ const index = pending.findIndex(edge => edge.id === id);
372
+ const focusInCard = Boolean(document.activeElement.closest(".card"));
373
+ setResolving(true);
374
+ try {
375
+ await request(`/api/edge/${encodeURIComponent(id)}/${action}`, { method:"POST" });
376
+ undoId = id;
377
+ $("#decision").hidden = false;
378
+ $("#decision-message").textContent = action === "accept" ? "Connection accepted." : "Suggestion dismissed.";
379
+ await refresh();
380
+ setResolving(false);
381
+ const next = pending[Math.min(index, pending.length - 1)];
382
+ if (next) {
383
+ selectEdge(next.id);
384
+ if (focusInCard) [...$("#cards").children].find(card => card.dataset.id === next.id)?.querySelector("button")?.focus();
385
+ } else if (focusInCard) $("#undo").focus();
386
+ } catch (error) {
387
+ notify(error instanceof TypeError ? "No connection. Your decision was not confirmed." : error.message, true);
388
+ await refresh();
389
+ } finally { setResolving(false); }
390
+ }
391
+
392
+ $("#undo").onclick = async () => {
393
+ if (!undoId || resolving) return;
394
+ setResolving(true);
395
+ try {
396
+ const id = undoId;
397
+ await request(`/api/edge/${encodeURIComponent(id)}/undo`, { method:"POST" });
398
+ undoId = null;
399
+ $("#decision").hidden = true;
400
+ await refresh();
401
+ selectEdge(id);
402
+ [...$("#cards").children].find(card => card.dataset.id === id)?.querySelector("button")?.focus();
403
+ notify("Decision undone.");
404
+ } catch (error) {
405
+ notify(error instanceof TypeError ? "No connection. The undo was not confirmed." : error.message, true);
406
+ } finally { setResolving(false); }
407
+ };
408
+ $("#dismiss-decision").onclick = () => { $("#decision").hidden = true; undoId = null; };
409
+
410
+ $("#bar").addEventListener("submit", async event => {
411
+ event.preventDefault();
412
+ const input = $("#text");
413
+ const text = input.value.trim();
414
+ if (!text || saving) return;
415
+ saving = true;
416
+ $("#ingest-button").disabled = true;
417
+ $("#ingest-button").textContent = "Saving …";
418
+ try {
419
+ const result = await request("/api/ingest", { method:"POST", headers:{ "Content-Type":"application/json" }, body:JSON.stringify({ text, source:$("#source").value }) });
420
+ if (input.value.trim() === text) input.value = "";
421
+ await refresh();
422
+ selectedEdge = null; selectedNode = result.node.id;
423
+ setView("graph"); highlight(); fitNodes(nodes.filter(node => node.id === selectedNode));
424
+ notify(result.duplicate ? "Duplicate detected and merged into the existing idea." : "Idea saved.");
425
+ } catch (error) {
426
+ notify(error instanceof TypeError ? "No connection. Your text is preserved. Check the graph before retrying." : error.message, true);
427
+ } finally {
428
+ saving = false;
429
+ $("#ingest-button").disabled = false;
430
+ $("#ingest-button").textContent = "Add idea";
431
+ }
432
+ });
433
+ $("#text").addEventListener("keydown", event => {
434
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); $("#bar").requestSubmit(); }
435
+ });
436
+
437
+ function renderDetail(id) {
438
+ const node = nodeById(id);
439
+ if (!node) { $("#detail").close(); return; }
440
+ $("#detail-text").textContent = node.text;
441
+ const date = new Date(node.created);
442
+ $("#detail-meta").textContent = [node.entity_type, ...(node.aliases || []), node.source, Number.isNaN(date.getTime()) ? null : date.toLocaleDateString("en-US"), ...(node.tags || []).map(tag => `#${tag}`)].filter(Boolean).join(" · ");
443
+ const relations = edges.filter(edge => edge.source === id || edge.target === id);
444
+ $("#detail-relations").innerHTML = `<h3>Connections (${relations.length})</h3>${relations.map(edge => {
445
+ const target = edge.source === id ? edge.target : edge.source;
446
+ return `<button class="relation quiet" data-node="${esc(target)}"><span>${esc(edgeLabel(edge))}${edge.pending ? " · Suggestion" : " · Accepted"}</span>${esc(short(textOf(target), 160))}</button>`;
447
+ }).join("") || '<p style="color:var(--dim)">No connections yet.</p>'}`;
448
+ }
449
+ function showDetail(id) {
450
+ if (!nodeById(id)) return;
451
+ detailId = id;
452
+ $("#tooltip").hidden = true;
453
+ renderDetail(id);
454
+ if (!$("#detail").open) $("#detail").showModal();
455
+ $("#detail").scrollTop = 0;
456
+ }
457
+ $("#detail-relations").onclick = event => {
458
+ const button = event.target.closest("button[data-node]");
459
+ if (button) { showDetail(button.dataset.node); $("#close-detail").focus(); }
460
+ };
461
+ $("#close-detail").onclick = () => $("#detail").close();
462
+ $("#detail").addEventListener("close", () => { detailId = null; });
463
+ $("#detail").addEventListener("click", event => {
464
+ const rect = $("#detail").getBoundingClientRect();
465
+ if (event.target === $("#detail") && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) $("#detail").close();
466
+ });
467
+
468
+ function closeSearch() { $("#search-results").hidden = true; $("#search").setAttribute("aria-expanded", "false"); }
469
+ function renderSearch() {
470
+ const query = $("#search").value.trim().toLowerCase();
471
+ highlight();
472
+ if (!query) { closeSearch(); return; }
473
+ const matches = nodes.filter(node => node.text.toLowerCase().includes(query)).slice(0, 12);
474
+ $("#search-results").innerHTML = matches.map(node => `<li><button data-node="${esc(node.id)}">${esc(short(node.text, 100))}</button></li>`).join("") || '<li class="no-results">No matching idea found.</li>';
475
+ $("#search-results").hidden = false;
476
+ $("#search").setAttribute("aria-expanded", "true");
477
+ }
478
+ $("#search").addEventListener("input", () => { clearSelection(); renderSearch(); });
479
+ $("#search").addEventListener("focus", renderSearch);
480
+ $("#search").addEventListener("keydown", event => {
481
+ if (event.key === "ArrowDown") { event.preventDefault(); $("#search-results button")?.focus(); }
482
+ if (event.key === "Enter") { event.preventDefault(); $("#search-results button")?.click(); }
483
+ });
484
+ $("#search-results").addEventListener("keydown", event => {
485
+ const buttons = [...document.querySelectorAll("#search-results button")];
486
+ const index = buttons.indexOf(document.activeElement);
487
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
488
+ event.preventDefault();
489
+ buttons[Math.max(0, Math.min(buttons.length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))]?.focus();
490
+ }
491
+ });
492
+ $("#search-results").addEventListener("click", event => {
493
+ const button = event.target.closest("button[data-node]");
494
+ if (!button) return;
495
+ selectedEdge = null; selectedNode = button.dataset.node; initialFit = false;
496
+ $("#search").value = ""; closeSearch(); highlight();
497
+ fitNodes(nodes.filter(node => node.id === selectedNode));
498
+ $("#search").focus(); showDetail(selectedNode);
499
+ });
500
+ document.addEventListener("pointerdown", event => { if (!event.target.closest(".search")) closeSearch(); });
501
+ document.addEventListener("focusin", event => { if (!event.target.closest(".search")) closeSearch(); });
502
+
503
+ window.addEventListener("keydown", event => {
504
+ if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey || $("#detail").open) return;
505
+ if (event.key === "Escape") {
506
+ closeSearch(); $("#search").value = ""; clearSelection();
507
+ if (document.activeElement.matches("input,textarea")) document.activeElement.blur();
508
+ return;
509
+ }
510
+ if (event.target.closest("input,textarea,select,[contenteditable='true']")) return;
511
+ // Native controls retain Enter/Space; shortcuts must never trigger a second action.
512
+ if (event.target.closest("button,[role='button']") && ["Enter", " "].includes(event.key)) return;
513
+ if (event.key.toLowerCase() === "i") { event.preventDefault(); $("#text").focus(); }
514
+ else if (event.key === "/") { event.preventDefault(); setView("graph"); $("#search").focus(); }
515
+ else if (["j", "k"].includes(event.key.toLowerCase()) && pending.length) {
516
+ event.preventDefault();
517
+ const index = pending.findIndex(edge => edge.id === selectedEdge);
518
+ const next = index < 0 ? (event.key.toLowerCase() === "j" ? 0 : pending.length - 1) : Math.max(0, Math.min(pending.length - 1, index + (event.key.toLowerCase() === "j" ? 1 : -1)));
519
+ selectEdge(pending[next].id);
520
+ document.querySelector(".card.active")?.scrollIntoView({ block:"nearest" });
521
+ } else if (event.key === "Enter" && selectedEdge) { event.preventDefault(); if (!event.repeat) resolveEdge(selectedEdge, "accept"); }
522
+ else if (event.key === " ") {
523
+ const id = selectedNode || pending.find(edge => edge.id === selectedEdge)?.target;
524
+ if (id) { event.preventDefault(); showDetail(id); }
525
+ }
526
+ });
527
+
528
+ // Audit #44: a half-open connection (laptop sleep, NAT timeout) kept showing
529
+ // "Live connected" while updates silently stopped. Ping every 25 s; if no
530
+ // reply arrives, the connection is considered dead and the reconnect (now
531
+ // exponential backoff + jitter instead of a fixed 5 s) takes over.
532
+ let attempts = 0, heartbeatTimer = null, awaitingPong = false;
533
+ function stopHeartbeat() { clearInterval(heartbeatTimer); }
534
+ // Stale check: send() on a half-open connection does not throw immediately —
535
+ // the server never answers, but the state machine stays OPEN. If 10 s after
536
+ // the ping neither a message nor a close arrived, close hard; onclose
537
+ // handles the backoff reconnect.
538
+ function startHeartbeatWithStaleCheck() {
539
+ stopHeartbeat();
540
+ heartbeatTimer = setInterval(() => {
541
+ if (socket?.readyState !== WebSocket.OPEN) return;
542
+ awaitingPong = true;
543
+ try { socket.send("ping"); } catch { socket.close(); return; }
544
+ setTimeout(() => {
545
+ if (awaitingPong && socket?.readyState === WebSocket.OPEN) socket.close(); // stale → onclose reconnects
546
+ }, 10000);
547
+ }, 25000);
548
+ }
549
+
550
+ function connect() {
551
+ clearTimeout(reconnectTimer);
552
+ stopHeartbeat();
553
+ socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
554
+ socket.onopen = () => {
555
+ attempts = 0;
556
+ $("#connection").dataset.state = "live";
557
+ $("#connection").textContent = "Live connected";
558
+ startHeartbeatWithStaleCheck();
559
+ refresh();
560
+ };
561
+ socket.onmessage = event => {
562
+ awaitingPong = false;
563
+ try { if (["ingested", "edge_resolved", "edge_restored", "edge_linked"].includes(JSON.parse(event.data).type)) scheduleRefresh(); } catch { /* Ignore unknown live messages. */ }
564
+ };
565
+ socket.onerror = () => socket.close();
566
+ socket.onclose = () => {
567
+ stopHeartbeat();
568
+ $("#connection").dataset.state = "offline";
569
+ $("#connection").textContent = "Live connection lost";
570
+ // Exponential backoff with jitter: 1s, 2s, 4s … max 60s — a downed server
571
+ // is no longer hammered on a fixed 5s cadence.
572
+ const delay = Math.min(60000, 1000 * 2 ** attempts) * (0.75 + Math.random() * 0.5);
573
+ attempts += 1;
574
+ reconnectTimer = setTimeout(connect, delay);
575
+ };
576
+ }
577
+ refresh(); connect();
578
+ })();