diff --git a/.gitignore b/.gitignore index 463388e..f06f9c1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,10 @@ internal/ # venv. The portable, env-configurable equivalent is the tracked scripts/mcp_server_http.py. /engraphis-mcp-http.py +# Generated screen-demo payload and encoded video. +/demo/generated/ +/demo/output/ + # FUSE mount artifacts: orphaned unlink-while-open handles from the Cowork # mount layer, not real content. Safe to ignore; delete host-side once the # holding process releases the handle (rm from the sandbox gets EPERM). diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d8981..45df523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ All notable changes to Engraphis are documented here. Format loosely follows ### Changed +- The legacy graph view now defaults to deterministic community islands, keeps sparse + influence bridges visually subordinate, and renders bounded direct A-MEM links even when + entity extraction is disabled. A reproducible repository-local screen-demo workflow exercises + session handoff, bi-temporal supersession, recall evidence, and history without external + services. - The public distribution is now structurally customer-only. License issuance, billing, fulfillment, Team identity, hosted relay, managed compute, worker execution, vendor administration, and commercial operations tooling moved to a private service repository; diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..a2328ca --- /dev/null +++ b/demo/README.md @@ -0,0 +1,29 @@ +# Engraphis memory-continuity screen demo + +This produces a silent 56-second MP4 showing the three proof points requested: + +1. A new `demo-agent` session boots from the previous repository handoff and recalls the v2 architecture context. +2. A same-subject fact changes; the resolver invalidates the old row while preserving it in history. +3. Retrieval evidence sits next to the Timeline chain, showing the retrieval arm, fused score, retention, provenance, and current/past validity. + +The payload is generated from a real in-memory `MemoryService` run before recording. No credentials, live services, or external APIs are used. + +Install the repository's Node dependencies and Chromium once, and ensure `ffmpeg` is on `PATH`: + +```powershell +npm ci +npx playwright install chromium +ffmpeg -version +``` + +From the repository root: + +```powershell +node demo/record_screen_demo.mjs +``` + +The finished video is written to `demo/output/engraphis-memory-demo.mp4`. To inspect only the data contract: + +```powershell +python demo/prepare_screen_demo.py +``` diff --git a/demo/engraphis_screen_demo.html b/demo/engraphis_screen_demo.html new file mode 100644 index 0000000..1e29141 --- /dev/null +++ b/demo/engraphis_screen_demo.html @@ -0,0 +1,279 @@ + + + + + + Engraphis · memory continuity demo + + + +
+
+
Engraphis/Memory continuity
+
local replay56 sec00:00
+
+
+
+
A screen demo for coding agents
+

Memory that survives
the next session.

+

An agent resumes repository context, updates a fact without erasing its past, then traces exactly why that context came back.

+ +
+ +
+
01 / start a session

The agent resumes with context.

A new session gets the last handoff before it asks the repository the first question.

+
+
workspace / defaultrepo / engraphis

The memory boundary is explicit. The agent does not start from an empty prompt or a flat namespace.

session scope and repository scope stay visible
+
agent console · demo-agentactive
+
$ engraphis_start_session(repo="engraphis")
+
✓ new session · ses_01…
+
bootstrap handoff
v2 is the current scoped, bi-temporal architecture.
+
open thread · Show why the architecture context was retrieved.
+
$ engraphis_recall("where should new capability live?")
+
Where to buildrecalled
New capability belongs in core and backends.
Matched by semantic retrieval · fused score 4.190 · retention 1.000
+
+
+
+ +
+
02 / change a fact

Updates close the old truth.
They do not delete it.

The resolver records an explicit supersession edge and leaves the prior version inspectable.

+
memory editor · Demo configurationresolver activity
+
past versionclosed

The screen demo records against the standard dashboard port 8700.

valid until this update
+
+
current versionlive

The screen demo records against port 8790 so it does not collide with a developer dashboard.

valid now
+
write-path decision
invalidate · same subject detected; previous memory preserved in history
+
+
one fact, two versions, one explainable chain
+
+ +
+
03 / explain the answer

Engraphis can show
why it retrieved context.

Retrieval evidence and bi-temporal history sit beside each other, not hidden behind a black box.

+
+

Why this was retrieved

recall evidence
query: where should new Engraphis capability live?
Where to build

New capability belongs in core and backends.

retrieval armsemantic
fused score4.190
retention1.000
provenancedemo-seed · trusted

The agent gets the answer, the score signal, and the source that produced it.

+

Timeline / Demo configuration

history preserved
Past versionpast
The standard dashboard port was 8700.
valid time · closedsource · demo-seed
Current versioncurrent
The demo uses 8790 to avoid a collision.
valid time · currentsource · demo-seed
+
+
+ +
+
A paper trail for agent memory
+

Recall the context.
Keep the history.

+
+

Engraphis makes memory scoped, temporal, and explainable across sessions and repositories.

+
what the agent knowswhy it knows ithow it changed
+
+
+
+
+ + + diff --git a/demo/prepare_screen_demo.py b/demo/prepare_screen_demo.py new file mode 100644 index 0000000..77bb97d --- /dev/null +++ b/demo/prepare_screen_demo.py @@ -0,0 +1,161 @@ +"""Seed a real in-memory Engraphis flow and export the screen-demo payload. + +The recording is intentionally short and deterministic at the UI level, but its +claims come from the v1 service facade used by the dashboard: a previous session +is ended, a new one boots from that handoff, a same-subject fact is superseded, +and the resulting recall/why/timeline/inspect responses are exported for the +visual layer. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from engraphis.service import MemoryService + + +WORKSPACE = "default" +REPO = "engraphis" +AGENT = "demo-agent" + + +def build_payload() -> dict: + svc = MemoryService.create(":memory:", embed_model="") + + seeded = svc.start_session( + WORKSPACE, + repo=REPO, + agent=AGENT, + goal="Seed the continuity story for the screen demo", + ) + svc.end_session( + seeded["session_id"], + summary=( + "v2 is the current scoped, bi-temporal architecture: build new capability " + "in engraphis/core and engraphis/backends. The dashboard exposes recall " + "and history." + ), + outcome="Seeded the handoff for the next agent session.", + open_threads=["Show why the architecture context was retrieved."], + ) + + session = svc.start_session( + WORKSPACE, + repo=REPO, + agent=AGENT, + goal="Record the 56-second memory continuity demo", + ) + assert session["bootstrap"]["summary"], "new session did not receive a handoff" + + architecture = svc.remember( + ( + "New Engraphis capability belongs in engraphis/core and engraphis/backends; " + "engraphis/app.py is the v1 legacy reference server." + ), + workspace=WORKSPACE, + repo=REPO, + session_id=session["session_id"], + title="Where to build", + importance=0.95, + source="demo-seed", + kind="demo_fixture", + ) + + old_endpoint = svc.remember( + "The screen demo records against the standard dashboard port 8700.", + workspace=WORKSPACE, + repo=REPO, + session_id=session["session_id"], + title="Demo configuration", + importance=0.80, + source="demo-seed", + kind="demo_fixture", + ) + current_endpoint = svc.remember( + ( + "The screen demo records against port 8790 so it does not collide with " + "a developer dashboard." + ), + workspace=WORKSPACE, + repo=REPO, + session_id=session["session_id"], + title="Demo configuration", + importance=0.90, + source="demo-seed", + kind="demo_fixture", + ) + assert current_endpoint["op"] == "invalidate", current_endpoint + + recall = svc.recall( + "where should new Engraphis capability live?", + workspace=WORKSPACE, + repo=REPO, + k=5, + reinforce=False, + ) + assert recall["count"] >= 1, "architecture context was not recallable" + recalled = next( + (memory for memory in recall["memories"] if memory["id"] == architecture["id"]), + None, + ) + assert recalled is not None, recall + + why = svc.why("demo configuration", workspace=WORKSPACE, repo=REPO) + timeline = svc.timeline("demo configuration", workspace=WORKSPACE, repo=REPO) + inspected = svc.inspect( + current_endpoint["id"], workspace=WORKSPACE, repo=REPO + ) + history = timeline["history"] + past = next( + (item for item in history if item["valid_to"] is not None or item["expired_at"] is not None), + None, + ) + live = next( + (item for item in history if item["valid_to"] is None and item["expired_at"] is None), + None, + ) + assert len(why["supersedes"]) == 1, why + assert why["supersedes"][0]["id"] == old_endpoint["id"], why + assert len(history) == 2, timeline + assert len(inspected["chain"]) == 2, inspected + assert past is not None, timeline + assert live is not None, timeline + + return { + "workspace": WORKSPACE, + "repo": REPO, + "session": session, + "recall": { + "query": recall["query"], + "memory": recalled, + }, + "why": { + "current": why["answer"][0], + "supersedes": why["supersedes"], + }, + "timeline": [past, live], + "inspection": { + "chain": inspected["chain"], + "events": [event for item in inspected["chain"] for event in item["events"]], + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + default="demo/generated/screen_demo_payload.json", + help="JSON payload path (created at runtime; ignored by git).", + ) + args = parser.parse_args() + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(build_payload(), indent=2), encoding="utf-8") + print(f"Prepared screen-demo payload: {output}") + + +if __name__ == "__main__": + main() diff --git a/demo/record_screen_demo.mjs b/demo/record_screen_demo.mjs new file mode 100644 index 0000000..1410027 --- /dev/null +++ b/demo/record_screen_demo.mjs @@ -0,0 +1,72 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createReadStream, mkdirSync, rmSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const demoDir = resolve(fileURLToPath(new URL(".", import.meta.url))); +const repoRoot = resolve(demoDir, ".."); +const generatedDir = join(demoDir, "generated"); +const outputDir = join(demoDir, "output"); +const payload = join(generatedDir, "screen_demo_payload.json"); +const webm = join(outputDir, "engraphis-memory-demo.webm"); +const mp4 = join(outputDir, "engraphis-memory-demo.mp4"); +const port = 8790; + +mkdirSync(generatedDir, { recursive: true }); +mkdirSync(outputDir, { recursive: true }); +const prepared = spawnSync(process.env.PYTHON || "python", [ + "-m", "demo.prepare_screen_demo", "--output", payload, +], { cwd: repoRoot, stdio: "inherit" }); +if (prepared.status !== 0) process.exit(prepared.status || 1); + +const server = createServer((request, response) => { + let relative; + try { + relative = decodeURIComponent((request.url || "/").split("?", 1)[0]); + } catch { + response.writeHead(400); response.end("Bad request"); return; + } + const requested = relative === "/" ? "engraphis_screen_demo.html" : relative.slice(1); + const file = resolve(demoDir, requested); + if (file !== demoDir && !file.startsWith(demoDir + sep)) { + response.writeHead(403); response.end("Forbidden"); return; + } + try { + const stat = statSync(file); + response.writeHead(200, { "Content-Length": stat.size, "Content-Type": file.endsWith(".json") ? "application/json" : "text/html" }); + createReadStream(file).pipe(response); + } catch { + response.writeHead(404); response.end("Not found"); + } +}); +await new Promise((resolveServer) => server.listen(port, "127.0.0.1", resolveServer)); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + recordVideo: { dir: outputDir, size: { width: 1920, height: 1080 } }, + deviceScaleFactor: 1, +}); +const page = await context.newPage(); +await page.goto(`http://127.0.0.1:${port}/engraphis_screen_demo.html?autoplay=1`, { waitUntil: "networkidle" }); +// Keep the capture clock independent from requestAnimationFrame throttling in +// headless environments; the page itself still stops its progress bar at 56s. +await page.waitForTimeout(56_500); +await context.close(); +await browser.close(); +server.close(); + +const recorded = await page.video().path(); +const ffmpeg = process.env.FFMPEG || "ffmpeg"; +const encoded = spawnSync(ffmpeg, [ + "-y", "-i", recorded, + "-c:v", "libx264", "-preset", "medium", "-crf", "20", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", mp4, +], { stdio: "inherit" }); +if (encoded.status !== 0) process.exit(encoded.status || 1); +if (recorded !== webm) { + try { rmSync(recorded, { force: true }); } catch { /* the MP4 is the deliverable */ } +} +console.log(`Wrote ${mp4}`); diff --git a/engraphis/service.py b/engraphis/service.py index bbc762f..d5c4284 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -5041,10 +5041,69 @@ def visible_entities(): selected_layers = {layer.value for layer in selected_graph_layers} # Nodes are capped at ``limit``; edges need their own cap or a large workspace # graph / indexed repo lets the lowest-privilege caller pull an unbounded - # payload (the SQL fetches are LIMIT-ed too, so server-side work stays - # bounded as well — entity edges sync from peers, so they are as attacker- - # growable as code edges). + # payload. The SQL fetches are limited too, so server-side work stays bounded. edge_cap = max(limit * 8, 2000) + # A workspace can legitimately have an A-MEM graph before it has extracted + # entities: direct memory links are first-class relationships, not merely an + # implementation detail of the code overlay. The old dashboard endpoint + # returned an empty graph in that case even though the complete Galaxy scene + # could already render the linked memories. Surface the bounded, live subset + # here as a useful fallback for both Graph-tab clients. + memory_link_fallback: list[dict] = [] + if not entity_rows and selected_graph_layers != []: + now = time.time() + sql = ( + "SELECT link.a, link.b, link.relation, " + "COALESCE(link.layer, 'semantic') AS layer, " + "COALESCE(link.reason, '') AS reason, " + "COALESCE(NULLIF(left_memory.title, ''), " + "substr(left_memory.content, 1, 80)) AS a_name, " + "left_memory.mtype AS a_mtype, " + "COALESCE(NULLIF(right_memory.title, ''), " + "substr(right_memory.content, 1, 80)) AS b_name, " + "right_memory.mtype AS b_mtype " + "FROM mem_links link " + "JOIN memories left_memory ON left_memory.id=link.a " + "JOIN memories right_memory ON right_memory.id=link.b " + "WHERE left_memory.workspace_id=? AND right_memory.workspace_id=? " + "AND COALESCE(left_memory.scope, 'workspace')!='session' " + "AND COALESCE(right_memory.scope, 'workspace')!='session' " + "AND (left_memory.valid_from IS NULL OR left_memory.valid_from<=?) " + "AND (left_memory.valid_to IS NULL OR ? limit: + continue + for prefix, memory_id in (("a", link["a"]), ("b", link["b"])): + fallback_nodes.setdefault(memory_id, { + "id": memory_id, + "name": ( + link[f"{prefix}_name"] + or memory_id + ), + "etype": f"memory_{link[f'{prefix}_mtype']}", + }) + memory_link_fallback.append({ + "a": link["a"], "b": link["b"], + "relation": link["relation"], "layer": link["layer"], + "reason": link["reason"], + }) + entity_rows = list(fallback_nodes.values()) visible_edge_ids = None if restrict_sessions: visible_edge_ids = { @@ -5073,6 +5132,15 @@ def visible_entities(): or (edge.layer.value if edge.layer else "semantic") in selected_layers ) ] + for link in memory_link_fallback: + if len(edgs) >= edge_cap: + break + edgs.append({ + "src": link["a"], "dst": link["b"], + "relation": link["relation"], + "layer": link.get("layer") or "semantic", + "reason": link.get("reason") or "", + }) repo_names: list[str] = [] if include_code: repo_rows = [] diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index f7726d0..2e4aec2 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -443,16 +443,16 @@ function renderSync(d){const el=document.getElementById('sync-body');if(!el)retu async function syncNow(){const b=document.getElementById('sync-btn');const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent='Sync now'}if(s)s.textContent='Sync failed — try again.'}} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ -let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; +let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; const GRAPH_PRESETS={ original:{label:'Original force',repel:120,link:30,gravity:14,font:13,size:3,linkw:1,labelDensity:40,curve:0,particles:0}, compact:{label:'Compact clusters',repel:42,link:20,gravity:26,font:12,size:3,linkw:.7,labelDensity:30,curve:.08,particles:0}, - communities:{label:'Community islands',repel:58,link:22,gravity:26,font:13,size:3,linkw:.8,labelDensity:50,curve:.16,particles:0}, + communities:{label:'Community islands',repel:48,link:16,gravity:48,font:12,size:3,linkw:.72,labelDensity:24,curve:.12,particles:0}, radial:{label:'Radial orbit',repel:68,link:26,gravity:12,font:13,size:3,linkw:.75,labelDensity:55,curve:.22,particles:0}, constellation:{label:'Constellation flow',repel:34,link:16,gravity:38,font:12,size:3,linkw:.65,labelDensity:35,curve:.32,particles:2}, custom:{label:'Custom tuning',curve:.1,particles:0} }; -window.GSET=window.GSET||{mode:'compact',font:12,size:3,repel:42,link:20,gravity:26,labels:false,linkw:.7,labelDensity:30,flow:true,frozen:false}; +window.GSET=window.GSET||{mode:'communities',font:12,size:3,repel:48,link:16,gravity:48,labels:false,linkw:.72,labelDensity:24,flow:true,frozen:false}; const ETYPE_TOKEN={person_or_concept:'--entity-concept',mention:'--entity-mention',hashtag:'--entity-hashtag',email:'--entity-email',organization:'--entity-organization',location:'--entity-location'}; const GRAPH_PALETTES={ theme:null, @@ -538,7 +538,7 @@ async function loadLegacyGraph(){ } const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(); try{ - GRAPH=await api('/graph?workspace='+encodeURIComponent(WS||'')+layerFilter+'&include_code='+(includeCode?'true':'false')+(repo?'&repo='+encodeURIComponent(repo):'')); + GRAPH=await api('/graph?workspace='+encodeURIComponent(WS||'')+layerFilter+'&include_code='+(includeCode?'true':'false')+(repo?'&repo='+encodeURIComponent(repo):'')); renderGraphSide();graphRender(); }catch(error){ showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); @@ -562,7 +562,19 @@ function graphData(){ const links=GRAPH.edges.filter(edge=>names.has(edge.from)&&names.has(edge.to)).map(edge=>({source:edge.from,target:edge.to,label:edge.label,layer:edge.layer||'semantic'})); const data={nodes,links};GDATA_CACHE={graph:GRAPH,hideIso,data};return data; } -function buildAdj(links){GADJ={};links.forEach(link=>{const source=(link.source&&link.source.id)||link.source,target=(link.target&&link.target.id)||link.target;(GADJ[source]=GADJ[source]||new Set()).add(target);(GADJ[target]=GADJ[target]||new Set()).add(source)})} +function buildAdj(links){ + GADJ={};GCOMM_ADJ={}; + links.forEach(link=>{ + const source=(link.source&&link.source.id)||link.source,target=(link.target&&link.target.id)||link.target; + (GADJ[source]=GADJ[source]||new Set()).add(target);(GADJ[target]=GADJ[target]||new Set()).add(source); + GCOMM_ADJ[source]=GCOMM_ADJ[source]||new Set();GCOMM_ADJ[target]=GCOMM_ADJ[target]||new Set(); + // Influence links often connect otherwise distinct bodies of work. Keep them + // visible, but do not let a few such bridges collapse all communities into one. + if(link.label!=='influences'){ + (GCOMM_ADJ[source]=GCOMM_ADJ[source]||new Set()).add(target);(GCOMM_ADJ[target]=GCOMM_ADJ[target]||new Set()).add(source); + } + }); +} function graphIndexComponents(nodes){ const seen=new Set(),components=[]; nodes.forEach(node=>{ @@ -580,10 +592,22 @@ function graphIndexComponents(nodes){ ids.forEach(id=>{GCOMPONENTS[id]={index,size:ids.length,x,y}}); }); } +function graphIndexCommunities(nodes){ + const groups={}; + nodes.forEach(node=>{const key=Number.isFinite(node.community)?node.community:0;(groups[key]=groups[key]||[]).push(node);}); + const communities=Object.entries(groups).sort((a,b)=>b[1].length-a[1].length); + const cols=Math.max(1,Math.ceil(Math.sqrt(communities.length))),gap=Math.max(150,window.GSET.link*9); + GCOMPONENTS={}; + communities.forEach(([key,members],index)=>{ + const row=Math.floor(index/cols),col=index%cols,used=Math.min(cols,communities.length-row*cols); + const x=(col-(used-1)/2)*gap,y=(row-(Math.ceil(communities.length/cols)-1)/2)*gap; + members.forEach(node=>{GCOMPONENTS[node.id]={index,size:members.length,x,y,community:Number(key)};}); + }); +} function graphRefreshComponentCenters(nodes,force=false){ const layout=window.GSET.mode+'|'+window.GSET.link; if(!force&&GCOMPONENT_LAYOUT===layout)return; - graphIndexComponents(nodes);GCOMPONENT_LAYOUT=layout; + if(window.GSET.mode==='communities')graphIndexCommunities(nodes);else graphIndexComponents(nodes);GCOMPONENT_LAYOUT=layout; } function graphAlpha(color,alpha){ const hex=/^#([0-9a-f]{6})$/i.exec(color||''); @@ -670,22 +694,18 @@ var GCOLORBY='community';try{var _cb=localStorage.getItem('engraphis-graph-color var GMAXDEG=1; function graphCommunityPalette(){return COMMUNITY_PALS[(typeof GSTYLE!=='undefined'&&COMMUNITY_PALS[GSTYLE])?GSTYLE:'classic'];} function graphComputeCommunities(nodes){ - var label={},ids=[];nodes.forEach(function(n,i){label[n.id]=i;ids.push(n.id);}); - for(var iter=0;iter<7;iter++){ - var changed=false; - for(var a=ids.length-1;a>0;a--){var b=Math.floor(Math.random()*(a+1));var t=ids[a];ids[a]=ids[b];ids[b]=t;} - for(var j=0;jbestC||(counts[l]===bestC&&l{const radius=Math.max(3,node.radius)+3;ctx.beginPath();ctx.arc(node.x,node.y,radius,0,2*Math.PI);ctx.fillStyle=color;ctx.fill()}); FG.linkColor(link=>{ const focus=GHOVERSET&&GHOVERSET.size>1,source=(link.source&&link.source.id)||link.source,target=(link.target&&link.target.id)||link.target,active=!focus||source===GHILITE||target===GHILITE; - const palette=window.GCOL.links[link.layer]||window.GCOL.links.semantic;return active?(focus?palette.active:palette.base):palette.dim; + const palette=window.GCOL.links[link.layer]||window.GCOL.links.semantic; + if(link.label==='influences')return active?graphAlpha(window.GCOL.layers[link.layer]||window.GCOL.layers.semantic,focus?.34:.18):palette.dim; + return active?(focus?palette.active:palette.base):palette.dim; }); - FG.linkWidth(link=>{const width=window.GSET.linkw||1,focus=GHOVERSET&&GHOVERSET.size>1;if(!focus)return (GPERF.dense?.62:.82)*width;const source=(link.source&&link.source.id)||link.source,target=(link.target&&link.target.id)||link.target;return (source===GHILITE||target===GHILITE)?1.8*width:.25*width}); + FG.linkWidth(link=>{const width=window.GSET.linkw||1,focus=GHOVERSET&&GHOVERSET.size>1,bridge=link.label==='influences';if(!focus)return (bridge?.45:(GPERF.dense?.62:.82))*width;const source=(link.source&&link.source.id)||link.source,target=(link.target&&link.target.id)||link.target;return (source===GHILITE||target===GHILITE)?(bridge?1.0:1.8)*width:.25*width}); if(FG.linkLineDash)FG.linkLineDash(GPERF.dense?null:(link=>link.layer==='temporal'?[4,3]:(link.layer==='causal'?[2,2]:null))); if(FG.linkCurvature)FG.linkCurvature(GPERF.dense?0:mode.curve); FG.linkDirectionalArrowLength(GPERF.dense?0:2.5).linkDirectionalArrowRelPos(1); @@ -892,7 +914,7 @@ function graphApplyPreset(name){ const notes={ original:'Original force graph · stronger repulsion and weak centering reproduce the previous, wider spacing.', compact:'Compact clusters · shorter links, gentler repulsion and stronger centering keep connected memories together.', - communities:'Community islands · connected components are packed around nearby component centers.', + communities:'Community islands · detected communities have their own gravity centers, while sparse bridges remain visible.', radial:'Radial orbit · high-degree entities settle near the center while lower-degree entities form outer rings.', constellation:'Constellation flow · curved directional relationships for smaller graphs.', custom:'Custom tuning · the appearance and physics controls below define this view.' diff --git a/playwright.config.js b/playwright.config.js index b40faa8..b7481fb 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -11,10 +11,11 @@ module.exports = defineConfig({ screenshot: 'only-on-failure', }, webServer: { - command: 'engraphis-dashboard --no-open --port 8700', + // Run the checked-out source, not a possibly stale globally installed console script. + command: 'python -m scripts.start_dashboard --no-open --port 8700', url: 'http://127.0.0.1:8700/api/health', timeout: 120_000, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, env: { ENGRAPHIS_EMBED_MODEL: '', ENGRAPHIS_LOOP_INTERVAL: '0', @@ -29,4 +30,3 @@ module.exports = defineConfig({ }, ], }); - diff --git a/tests/test_service_graph.py b/tests/test_service_graph.py index c8581e2..4e7bec5 100644 --- a/tests/test_service_graph.py +++ b/tests/test_service_graph.py @@ -311,6 +311,102 @@ def test_graph_lazy_backfills_preexisting_memories(): assert "Alice Johnson" in labels and "Acme Corp" in labels +def test_graph_falls_back_to_direct_memory_links_without_entities(): + """A-MEM links form a useful graph even when entity extraction is disabled.""" + svc = MemoryService.create(":memory:", graph_extractor="none") + first = svc.remember("Synthetic alpha", workspace="acme", scope="workspace") + second = svc.remember("Synthetic beta", workspace="acme", scope="workspace") + svc.link(first["id"], second["id"], workspace="acme", relation="causes") + + graph = svc.graph(workspace="acme") + assert {node["id"] for node in graph["nodes"]} == {first["id"], second["id"]} + assert {row["etype"] for row in graph["types"]} == {"memory_semantic"} + assert { + "from": first["id"], "to": second["id"], + "label": "causes", "layer": "causal", + } in graph["edges"] + + +def test_graph_memory_link_fallback_excludes_session_scope(): + """The workspace graph must never surface private session memories.""" + svc = MemoryService.create(":memory:", graph_extractor="none") + session = svc.start_session("acme", repo="r", agent="codex", goal="private") + private_a = svc.remember( + "Private alpha", workspace="acme", repo="r", + session_id=session["session_id"], scope="session", + ) + private_b = svc.remember( + "Private beta", workspace="acme", repo="r", + session_id=session["session_id"], scope="session", + ) + svc.link(private_a["id"], private_b["id"], workspace="acme", relation="causes") + + assert svc.graph(workspace="acme")["nodes"] == [] + + +def test_graph_memory_link_fallback_respects_empty_layer_filter(): + svc = MemoryService.create(":memory:", graph_extractor="none") + first = svc.remember("Synthetic alpha", workspace="acme", scope="workspace") + second = svc.remember("Synthetic beta", workspace="acme", scope="workspace") + svc.link(first["id"], second["id"], workspace="acme", relation="causes") + + graph = svc.graph(workspace="acme", layers=[]) + assert graph["nodes"] == [] + assert graph["edges"] == [] + + +def test_graph_memory_link_fallback_samples_links_before_unrelated_memories(): + """A small graph limit must still select connected memories.""" + svc = MemoryService.create(":memory:", graph_extractor="none") + svc.engine.auto_evolve = False + for index in range(5): + svc.remember( + f"Unlinked memory {index}", workspace="acme", scope="workspace" + ) + first = svc.remember( + "Orchid deployment requires manual approval.", + workspace="acme", scope="workspace", + ) + second = svc.remember( + "Jupiter telemetry is retained for thirty days.", + workspace="acme", scope="workspace", + ) + svc.link(first["id"], second["id"], workspace="acme", relation="causes") + + graph = svc.graph(workspace="acme", limit=2) + + assert {node["id"] for node in graph["nodes"]} == {first["id"], second["id"]} + assert graph["edges"] == [{ + "from": first["id"], "to": second["id"], + "label": "causes", "layer": "causal", + }] + + +def test_graph_memory_link_fallback_projects_bounded_content_excerpts(): + """Fallback SQL must not materialize full memory bodies just to label nodes.""" + svc = MemoryService.create(":memory:", graph_extractor="none") + svc.engine.auto_evolve = False + first = svc.remember("A" * 100_000, workspace="acme", scope="workspace") + second = svc.remember("B" * 100_000, workspace="acme", scope="workspace") + svc.link(first["id"], second["id"], workspace="acme", relation="causes") + statements = [] + svc.store.conn.set_trace_callback(statements.append) + + try: + graph = svc.graph(workspace="acme", limit=2) + finally: + svc.store.conn.set_trace_callback(None) + + assert {node["label"] for node in graph["nodes"]} == {"A" * 80, "B" * 80} + fallback_queries = [sql for sql in statements if "FROM mem_links link" in sql] + assert len(fallback_queries) == 1 + projection = fallback_queries[0].lower() + assert "substr(left_memory.content, 1, 80)" in projection + assert "substr(right_memory.content, 1, 80)" in projection + assert "left_memory.content as" not in projection + assert "right_memory.content as" not in projection + + def test_graph_lazy_backfill_is_idempotent(): """Re-opening the Graph tab must not duplicate entities.""" svc = MemoryService.create(":memory:", graph_extractor="regex")